public inbox for [email protected]  
help / color / mirror / Atom feed
[pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
16+ messages / 4 participants
[nested] [flat]

* [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 08:55  Khushboo Vashi <[email protected]>
  0 siblings, 2 replies; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-17 08:55 UTC (permalink / raw)
  To: pgadmin-hackers

Hi,

Please find the attached patch to support LDAP Authentication in Server
mode.
To test the patch, config_auth.py needs to be configured for LDAP
configurations. The config settings are explained in this file in detail.
After configuring the parameters, start the pgadmin server in Server mode
and connect with LDAP server with the valid user via login page.

I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I have
used the default config of ldap3 without certificates.

@Dave, can you please review this patch, as you have a better understanding
of LDAP and you can easily pointed out if I have missed anything.

Note: For the document update I will create the task and assign to Nidhi
for the same.

Thanks,
Khushboo


Attachments:

  [application/octet-stream] RM_2186.patch (33.3K, 3-RM_2186.patch)
  download | inline diff:
diff --git a/README b/README
index 26e2ef346..f09da3691 100644
--- a/README
+++ b/README
@@ -162,6 +162,8 @@ process is fairly simple - adapt as required for your distribution:
    This configuration allows easy switching between server and desktop modes
    for testing.
 
+   Edit $PGADMIN4_SRC/web/config_auth.py to enable LDAP Authentication.
+
 6) The initial setup of the configuration database is interactive in server
    mode,  and non-interactive in desktop mode. You can run it either by
    running:
diff --git a/requirements.txt b/requirements.txt
index c5d1c56eb..1be16960a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3==2.5.1
diff --git a/web/config.py b/web/config.py
index c26903310..91f20a12d 100644
--- a/web/config.py
+++ b/web/config.py
@@ -492,6 +492,12 @@ ENHANCED_COOKIE_PROTECTION = True
 # Local config settings
 ##########################################################################
 
+# Load local authentication config overrides
+try:
+    from config_auth import *
+except ImportError:
+    pass
+
 # Load distribution-specific config overrides
 try:
     from config_distro import *
diff --git a/web/config_auth.py b/web/config_auth.py
new file mode 100644
index 000000000..51a8307ca
--- /dev/null
+++ b/web/config_auth.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+# config_auth.py -  External Authentication configuration settings
+#
+##########################################################################
+
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+CA_CERT_FILE = ''
+CERT_FILE = ''
+KEY_FILE = ''
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..2b46a49e6
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'INTERNAL',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..cc263a96d
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,98 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input data will be validated and authenticated.
+    """
+    form = _security.login_form()
+
+    # Loop through all the sources
+    for src in config.AUTHENTICATION_SOURCES:
+        source = get_auth_sources(src)
+
+        # Validate the user
+        if not source.validate(form):
+            for field in form.errors:
+                for error in form.errors[field]:
+                    flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+        # Authenticate the user
+        status, msg = source.authenticate()
+
+        # Login the user if authenticated else look for the
+        # other authentication sources if set in the config
+        if status:
+            # Login the user
+            status, msg = source.login()
+            if not status:
+                flash(gettext(msg), 'danger')
+                return flask.redirect(get_post_logout_redirect())
+
+            return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+def get_auth_sources(type, app=None):
+    """Get the authenticated source object from the registry"""
+    if app is not None:
+        AuthSourceRegistry.load_auth_sources()
+
+    auth_sources = getattr(app or current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(app or current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..18a376caf
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,91 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+    def __init__(self):
+        self.form = None
+        self.username = None
+        self.password = None
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+        return True
+
+    def login(self):
+        user = getattr(self.form, 'user',
+                       User.query.filter_by(username=self.username).first())
+
+        if user is None:
+            current_app.logger.exception(self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    @staticmethod
+    def messages(msg_key):
+        _default_msg = {
+            'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+            'LOGIN_FAILED': 'Login failed',
+            'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+            'PASSWORD_NOT_PROVIDED': 'Password not provided'
+        }
+        return _default_msg[msg_key] if msg_key in _default_msg else None
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def validate(self, form):
+        """User validation"""
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        # Flask security validation
+        return self.form.validate_on_submit()
+
+    def authenticate(self):
+        return True, None
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..265ade19a
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,157 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+
+    def authenticate(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'CA_CERT_FILE', None)
+            cert_file = getattr(config, 'CERT_FILE', None)
+            key_file = getattr(config, 'KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        status, msg = self.__search_ldap_user()
+
+        if not status:
+            return status, msg
+
+        return self.__auto_create_user()
+
+    def __auto_create_user(self):
+        if config.AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': '',
+                    'role': 2,
+                    'active': True,
+                    'newPassword': self.password,
+                    'confirmPassword': self.password
+                })
+
+        return True, None
+
+    def __search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.SEARCH_BASE_DN,
+                             search_filter=config.SEARCH_FILTER,
+                             search_scope=config.SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            if config.USERNAME_ATTRIBUTE in entry and \
+                    self.username == entry[config.USERNAME_ATTRIBUTE].value:
+                return True, None
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..fc4005ed0 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -580,12 +580,18 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..f894623e3 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -149,6 +150,7 @@ window.onload = function(e){
                             {{ _('Change Password') }}
                         </a>
                     </li>
+                    {% endif %}
                     <li class="dropdown-divider"></li>
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..873e8c658 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -176,13 +176,20 @@ def user(uid):
 
     """
 
+    auth_only_internal = False
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        auth_only_internal = True
+
     if uid:
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_only_internal': auth_only_internal,
                }
     else:
         users = User.query.all()
@@ -190,9 +197,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_only_internal': auth_only_internal,
                                })
 
         res = users_data
@@ -215,11 +224,31 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+    else:
+        req_params = ('username', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +257,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+        if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+                current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+            username = new_data['email']
+            email = new_data['email']
+        else:
+            username = data['username']
+            email = getattr(new_data, 'email', '')
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=new_data['password'],
+                   auth_source=current_app.PGADMIN_EXTERNAL_AUTH_SOURCE)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +281,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,6 +373,7 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
                'role': usr.roles[0].id
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 64014863f..57275eef9 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -242,13 +242,27 @@ define([
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_only_internal: true,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            visible: function(m) {
+              if (m.get('auth_only_internal')) return false;
+              return true;
+            },
+            disabled: false,
+          },{
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +270,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('auth_only_internal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -344,7 +360,7 @@ define([
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_only_internal') === true && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 10:06  navnath gadakh <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  1 sibling, 1 reply; 16+ messages in thread

From: navnath gadakh @ 2020-03-17 10:06 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; +Cc: pgadmin-hackers

Hi Khushboo,
       I think there is no use of

+    if app is not None:
+        AuthSourceRegistry.load_auth_sources()
+

in get_auth_sources() function.


On Tue, Mar 17, 2020 at 2:25 PM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Please find the attached patch to support LDAP Authentication in Server
> mode.
> To test the patch, config_auth.py needs to be configured for LDAP
> configurations. The config settings are explained in this file in detail.
> After configuring the parameters, start the pgadmin server in Server mode
> and connect with LDAP server with the valid user via login page.
>
> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
> have used the default config of ldap3 without certificates.
>
> @Dave, can you please review this patch, as you have a better
> understanding of LDAP and you can easily pointed out if I have missed
> anything.
>
> Note: For the document update I will create the task and assign to Nidhi
> for the same.
>
> Thanks,
> Khushboo
>


-- 
*-- Navnath*


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 10:12  Dave Page <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  1 sibling, 1 reply; 16+ messages in thread

From: Dave Page @ 2020-03-17 10:12 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; +Cc: pgadmin-hackers

Hi

30 second read of the first version of the patch...

- Please move the configuration into config.py. Users should never have to
modify a distributed file (it messes up packaging). I don't see any reason
to use a different file just for auth config.

- I think all config options should be prefixed with LDAP_ as we may have
things like CERT_FILE for other purposes too.

- I don't see any test cases.

Thanks.


On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Please find the attached patch to support LDAP Authentication in Server
> mode.
> To test the patch, config_auth.py needs to be configured for LDAP
> configurations. The config settings are explained in this file in detail.
> After configuring the parameters, start the pgadmin server in Server mode
> and connect with LDAP server with the valid user via login page.
>
> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
> have used the default config of ldap3 without certificates.
>
> @Dave, can you please review this patch, as you have a better
> understanding of LDAP and you can easily pointed out if I have missed
> anything.
>
> Note: For the document update I will create the task and assign to Nidhi
> for the same.
>
> Thanks,
> Khushboo
>


-- 
Dave Page
Blog: http://pgsnake.blogspot.com
Twitter: @pgsnake

EnterpriseDB UK: http://www.enterprisedb.com
The Enterprise PostgreSQL Company


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 10:23  Khushboo Vashi <[email protected]>
  parent: Dave Page <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-17 10:23 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Hi Dave,

Thanks for the review.

On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:

> Hi
>
> 30 second read of the first version of the patch...
>
> - Please move the configuration into config.py. Users should never have to
> modify a distributed file (it messes up packaging). I don't see any reason
> to use a different file just for auth config.
>
> There are many settings for the LDAP, and in the future we will add other
external sources also, so I thought it would be better if we have different
file for the authentication.

> - I think all config options should be prefixed with LDAP_ as we may have
> things like CERT_FILE for other purposes too.
>
> Sure.

> - I don't see any test cases.
>
> I will think about this, as right now no idea how to write test cases for
this.

> Thanks.
>
> Thanks,
Khushboo

>
> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi,
>>
>> Please find the attached patch to support LDAP Authentication in Server
>> mode.
>> To test the patch, config_auth.py needs to be configured for LDAP
>> configurations. The config settings are explained in this file in detail.
>> After configuring the parameters, start the pgadmin server in Server mode
>> and connect with LDAP server with the valid user via login page.
>>
>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
>> have used the default config of ldap3 without certificates.
>>
>> @Dave, can you please review this patch, as you have a better
>> understanding of LDAP and you can easily pointed out if I have missed
>> anything.
>>
>> Note: For the document update I will create the task and assign to Nidhi
>> for the same.
>>
>> Thanks,
>> Khushboo
>>
>
>
> --
> Dave Page
> Blog: http://pgsnake.blogspot.com
> Twitter: @pgsnake
>
> EnterpriseDB UK: http://www.enterprisedb.com
> The Enterprise PostgreSQL Company
>


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 10:24  Khushboo Vashi <[email protected]>
  parent: navnath gadakh <[email protected]>
  0 siblings, 0 replies; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-17 10:24 UTC (permalink / raw)
  To: navnath gadakh <[email protected]>; +Cc: pgadmin-hackers

Hi Navnath,

On Tue, Mar 17, 2020 at 3:37 PM navnath gadakh <
[email protected]> wrote:

> Hi Khushboo,
>        I think there is no use of
>
> +    if app is not None:
> +        AuthSourceRegistry.load_auth_sources()
> +
>
> in get_auth_sources() function.
>
> Thanks for the review, I will look into it.

Thanks,
Khushboo

>
> On Tue, Mar 17, 2020 at 2:25 PM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi,
>>
>> Please find the attached patch to support LDAP Authentication in Server
>> mode.
>> To test the patch, config_auth.py needs to be configured for LDAP
>> configurations. The config settings are explained in this file in detail.
>> After configuring the parameters, start the pgadmin server in Server mode
>> and connect with LDAP server with the valid user via login page.
>>
>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
>> have used the default config of ldap3 without certificates.
>>
>> @Dave, can you please review this patch, as you have a better
>> understanding of LDAP and you can easily pointed out if I have missed
>> anything.
>>
>> Note: For the document update I will create the task and assign to Nidhi
>> for the same.
>>
>> Thanks,
>> Khushboo
>>
>
>
> --
> *-- Navnath*
>


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-17 10:41  Dave Page <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Dave Page @ 2020-03-17 10:41 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; +Cc: pgadmin-hackers

Hi

On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
[email protected]> wrote:

> Hi Dave,
>
> Thanks for the review.
>
> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>
>> Hi
>>
>> 30 second read of the first version of the patch...
>>
>> - Please move the configuration into config.py. Users should never have
>> to modify a distributed file (it messes up packaging). I don't see any
>> reason to use a different file just for auth config.
>>
>> There are many settings for the LDAP, and in the future we will add other
> external sources also, so I thought it would be better if we have different
> file for the authentication.
>

Sure, but our config file is small compared to many. Splitting things out
is more confusing for users. If they want to do that themselves of course,
they can add a config_local.py file which includes other files as needed.


> - I think all config options should be prefixed with LDAP_ as we may have
>> things like CERT_FILE for other purposes too.
>>
>> Sure.
>
>> - I don't see any test cases.
>>
>> I will think about this, as right now no idea how to write test cases for
> this.
>

It should be fairly straightforward to write tests for some of the
functions in the auth classes. For testing the actual LDAP stuff, we
probably need to add LDAP config options to test_config.json, and only if
present, run the tests. That would probably need to support a list of LDAP
servers, so we can test with different configurations (LDAP, LDAPS,
LDAP_STARTTLS, AD etc).


> Thanks.
>>
>> Thanks,
> Khushboo
>
>>
>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi,
>>>
>>> Please find the attached patch to support LDAP Authentication in Server
>>> mode.
>>> To test the patch, config_auth.py needs to be configured for LDAP
>>> configurations. The config settings are explained in this file in detail.
>>> After configuring the parameters, start the pgadmin server in Server mode
>>> and connect with LDAP server with the valid user via login page.
>>>
>>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
>>> have used the default config of ldap3 without certificates.
>>>
>>> @Dave, can you please review this patch, as you have a better
>>> understanding of LDAP and you can easily pointed out if I have missed
>>> anything.
>>>
>>> Note: For the document update I will create the task and assign to Nidhi
>>> for the same.
>>>
>>> Thanks,
>>> Khushboo
>>>
>>
>>
>> --
>> Dave Page
>> Blog: http://pgsnake.blogspot.com
>> Twitter: @pgsnake
>>
>> EnterpriseDB UK: http://www.enterprisedb.com
>> The Enterprise PostgreSQL Company
>>
>

-- 
Dave Page
Blog: http://pgsnake.blogspot.com
Twitter: @pgsnake

EnterpriseDB UK: http://www.enterprisedb.com
The Enterprise PostgreSQL Company


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-24 04:59  Khushboo Vashi <[email protected]>
  parent: Dave Page <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-24 04:59 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Hi,

Please find the attached updated patch.


On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:

> Hi
>
> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi Dave,
>>
>> Thanks for the review.
>>
>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>
>>> Hi
>>>
>>> 30 second read of the first version of the patch...
>>>
>>> - Please move the configuration into config.py. Users should never have
>>> to modify a distributed file (it messes up packaging). I don't see any
>>> reason to use a different file just for auth config.
>>>
>>> There are many settings for the LDAP, and in the future we will add
>> other external sources also, so I thought it would be better if we have
>> different file for the authentication.
>>
>
> Sure, but our config file is small compared to many. Splitting things out
> is more confusing for users. If they want to do that themselves of course,
> they can add a config_local.py file which includes other files as needed.
>
Fixed.

>
>
>> - I think all config options should be prefixed with LDAP_ as we may have
>>> things like CERT_FILE for other purposes too.
>>>
>>> Sure.
>>
> Done.

> - I don't see any test cases.
>>>
>>> I will think about this, as right now no idea how to write test cases
>> for this.
>>
>
> It should be fairly straightforward to write tests for some of the
> functions in the auth classes. For testing the actual LDAP stuff, we
> probably need to add LDAP config options to test_config.json, and only if
> present, run the tests. That would probably need to support a list of LDAP
> servers, so we can test with different configurations (LDAP, LDAPS,
> LDAP_STARTTLS, AD etc).
>
>
Done.

Thanks,
Khushboo

> Thanks.
>>>
>>> Thanks,
>> Khushboo
>>
>>>
>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Hi,
>>>>
>>>> Please find the attached patch to support LDAP Authentication in Server
>>>> mode.
>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>> configurations. The config settings are explained in this file in detail.
>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>> and connect with LDAP server with the valid user via login page.
>>>>
>>>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
>>>> have used the default config of ldap3 without certificates.
>>>>
>>>> @Dave, can you please review this patch, as you have a better
>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>> anything.
>>>>
>>>> Note: For the document update I will create the task and assign to
>>>> Nidhi for the same.
>>>>
>>>> Thanks,
>>>> Khushboo
>>>>
>>>
>>>
>>> --
>>> Dave Page
>>> Blog: http://pgsnake.blogspot.com
>>> Twitter: @pgsnake
>>>
>>> EnterpriseDB UK: http://www.enterprisedb.com
>>> The Enterprise PostgreSQL Company
>>>
>>
>
> --
> Dave Page
> Blog: http://pgsnake.blogspot.com
> Twitter: @pgsnake
>
> EnterpriseDB UK: http://www.enterprisedb.com
> The Enterprise PostgreSQL Company
>


Attachments:

  [application/octet-stream] RM_2186_v1.patch (46.1K, 3-RM_2186_v1.patch)
  download | inline diff:
diff --git a/requirements.txt b/requirements.txt
index c5d1c56eb..1be16960a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3==2.5.1
diff --git a/web/config.py b/web/config.py
index c26903310..55bed555f 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..2b46a49e6
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'INTERNAL',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..e71890ce0
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,96 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input data will be validated and authenticated.
+    """
+    form = _security.login_form()
+
+    # Loop through all the sources
+    for src in config.AUTHENTICATION_SOURCES:
+        source = get_auth_sources(src)
+
+        # Validate the user
+        if not source.validate(form):
+            for field in form.errors:
+                for error in form.errors[field]:
+                    flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+        # Authenticate the user
+        status, msg = source.authenticate()
+
+        # Login the user if authenticated else look for the
+        # other authentication sources if set in the config
+        if status:
+            # Login the user
+            status, msg = source.login()
+            if not status:
+                flash(gettext(msg), 'danger')
+                return flask.redirect(get_post_logout_redirect())
+
+            return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..18a376caf
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,91 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+    def __init__(self):
+        self.form = None
+        self.username = None
+        self.password = None
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+        return True
+
+    def login(self):
+        user = getattr(self.form, 'user',
+                       User.query.filter_by(username=self.username).first())
+
+        if user is None:
+            current_app.logger.exception(self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    @staticmethod
+    def messages(msg_key):
+        _default_msg = {
+            'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+            'LOGIN_FAILED': 'Login failed',
+            'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+            'PASSWORD_NOT_PROVIDED': 'Password not provided'
+        }
+        return _default_msg[msg_key] if msg_key in _default_msg else None
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def validate(self, form):
+        """User validation"""
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        # Flask security validation
+        return self.form.validate_on_submit()
+
+    def authenticate(self):
+        return True, None
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..f2c0ca7bc
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,164 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+
+    def authenticate(self):
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, msg = self.search_ldap_user()
+
+        if not status:
+            return status, msg
+
+        return self.__auto_create_user()
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+    def __auto_create_user(self):
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': '',
+                    'role': 2,
+                    'active': True,
+                    'newPassword': self.password,
+                    'confirmPassword': self.password
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                return True, None
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/authenticate/tests/__init__.py b/web/pgadmin/authenticate/tests/__init__.py
new file mode 100644
index 000000000..7af45b1b5
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/__init__.py
@@ -0,0 +1,8 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
diff --git a/web/pgadmin/authenticate/tests/test_ldap.py b/web/pgadmin/authenticate/tests/test_ldap.py
new file mode 100644
index 000000000..000f2d0b6
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/test_ldap.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.utils import server_utils as server_utils
+from regression import parent_node_dict
+import simplejson as json
+
+
+class DashboardGraphsTestCase(BaseTestGenerator):
+    """
+    This class validates the version in range functionality
+    by defining different version scenarios; where dict of
+    parameters describes the scenario appended by test name.
+    """
+
+    scenarios = [(
+        'TestCase for session_stats graph', dict(
+            did=-1,
+            chart_data={
+                'session_stats': ['Total', 'Active', 'Idle'],
+            }
+        ))
+    ]
+
+    def setUp(self):
+        pass
+
+    def getStatsUrl(self, sid=-1, did=-1, chart_names=''):
+        base_url = '/dashboard/dashboard_stats'
+        base_url = base_url + '/' + str(sid)
+        base_url += '/' + str(did) if did > 0 else ''
+        base_url += '?chart_names=' + chart_names
+        return base_url
+
+    def runTest(self):
+        self.server_id = parent_node_dict["server"][-1]["server_id"]
+        server_response = server_utils.connect_server(self, self.server_id)
+        if server_response["info"] == "Server connected.":
+
+            url = self.getStatsUrl(self.server_id, self.did,
+                                   ",".join(self.chart_data.keys()))
+            response = self.tester.get(url)
+            self.assertEquals(response.status_code, 200)
+
+            resp_data = json.loads(response.data)
+
+            # All requested charts received
+            self.assertEquals(len(resp_data.keys()),
+                              len(self.chart_data.keys()))
+
+            # All requested charts data received
+            for chart_name, chart_vals in self.chart_data.items():
+                self.assertEquals(set(resp_data[chart_name].keys()),
+                                  set(chart_vals))
+
+        else:
+            raise Exception("Error while connecting server to add the"
+                            " database.")
+
+    def tearDown(self):
+        pass
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..fc4005ed0 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -580,12 +580,18 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..f894623e3 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -149,6 +150,7 @@ window.onload = function(e){
                             {{ _('Change Password') }}
                         </a>
                     </li>
+                    {% endif %}
                     <li class="dropdown-divider"></li>
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..1aa8f7c33
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, "Done"])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/schema_diff/tests/pg/10_plus/diff_22485.sql b/web/pgadmin/tools/schema_diff/tests/pg/10_plus/diff_22485.sql
new file mode 100644
index 000000000..e69de29bb
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..873e8c658 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -176,13 +176,20 @@ def user(uid):
 
     """
 
+    auth_only_internal = False
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        auth_only_internal = True
+
     if uid:
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_only_internal': auth_only_internal,
                }
     else:
         users = User.query.all()
@@ -190,9 +197,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_only_internal': auth_only_internal,
                                })
 
         res = users_data
@@ -215,11 +224,31 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+    else:
+        req_params = ('username', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +257,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+        if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+                current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+            username = new_data['email']
+            email = new_data['email']
+        else:
+            username = data['username']
+            email = getattr(new_data, 'email', '')
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=new_data['password'],
+                   auth_source=current_app.PGADMIN_EXTERNAL_AUTH_SOURCE)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +281,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,6 +373,7 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
                'role': usr.roles[0].id
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 64014863f..57275eef9 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -242,13 +242,27 @@ define([
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_only_internal: true,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            visible: function(m) {
+              if (m.get('auth_only_internal')) return false;
+              return true;
+            },
+            disabled: false,
+          },{
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +270,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('auth_only_internal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -344,7 +360,7 @@ define([
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_only_internal') === true && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-24 05:02  Khushboo Vashi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-24 05:02 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Please disregard my previous patch, attached the updated patch.

On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Please find the attached updated patch.
>
>
> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>
>> Hi
>>
>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi Dave,
>>>
>>> Thanks for the review.
>>>
>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>>
>>>> Hi
>>>>
>>>> 30 second read of the first version of the patch...
>>>>
>>>> - Please move the configuration into config.py. Users should never have
>>>> to modify a distributed file (it messes up packaging). I don't see any
>>>> reason to use a different file just for auth config.
>>>>
>>>> There are many settings for the LDAP, and in the future we will add
>>> other external sources also, so I thought it would be better if we have
>>> different file for the authentication.
>>>
>>
>> Sure, but our config file is small compared to many. Splitting things out
>> is more confusing for users. If they want to do that themselves of course,
>> they can add a config_local.py file which includes other files as needed.
>>
> Fixed.
>
>>
>>
>>> - I think all config options should be prefixed with LDAP_ as we may
>>>> have things like CERT_FILE for other purposes too.
>>>>
>>>> Sure.
>>>
>> Done.
>
>> - I don't see any test cases.
>>>>
>>>> I will think about this, as right now no idea how to write test cases
>>> for this.
>>>
>>
>> It should be fairly straightforward to write tests for some of the
>> functions in the auth classes. For testing the actual LDAP stuff, we
>> probably need to add LDAP config options to test_config.json, and only if
>> present, run the tests. That would probably need to support a list of LDAP
>> servers, so we can test with different configurations (LDAP, LDAPS,
>> LDAP_STARTTLS, AD etc).
>>
>>
> Done.
>
> Thanks,
> Khushboo
>
>> Thanks.
>>>>
>>>> Thanks,
>>> Khushboo
>>>
>>>>
>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Hi,
>>>>>
>>>>> Please find the attached patch to support LDAP Authentication in
>>>>> Server mode.
>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>> configurations. The config settings are explained in this file in detail.
>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>> and connect with LDAP server with the valid user via login page.
>>>>>
>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS, I
>>>>> have used the default config of ldap3 without certificates.
>>>>>
>>>>> @Dave, can you please review this patch, as you have a better
>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>> anything.
>>>>>
>>>>> Note: For the document update I will create the task and assign to
>>>>> Nidhi for the same.
>>>>>
>>>>> Thanks,
>>>>> Khushboo
>>>>>
>>>>
>>>>
>>>> --
>>>> Dave Page
>>>> Blog: http://pgsnake.blogspot.com
>>>> Twitter: @pgsnake
>>>>
>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>> The Enterprise PostgreSQL Company
>>>>
>>>
>>
>> --
>> Dave Page
>> Blog: http://pgsnake.blogspot.com
>> Twitter: @pgsnake
>>
>> EnterpriseDB UK: http://www.enterprisedb.com
>> The Enterprise PostgreSQL Company
>>
>


Attachments:

  [application/octet-stream] RM_2186_v1.patch (45.9K, 3-RM_2186_v1.patch)
  download | inline diff:
diff --git a/requirements.txt b/requirements.txt
index c5d1c56eb..1be16960a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3==2.5.1
diff --git a/web/config.py b/web/config.py
index c26903310..55bed555f 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..2b46a49e6
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'INTERNAL',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..e71890ce0
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,96 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input data will be validated and authenticated.
+    """
+    form = _security.login_form()
+
+    # Loop through all the sources
+    for src in config.AUTHENTICATION_SOURCES:
+        source = get_auth_sources(src)
+
+        # Validate the user
+        if not source.validate(form):
+            for field in form.errors:
+                for error in form.errors[field]:
+                    flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+        # Authenticate the user
+        status, msg = source.authenticate()
+
+        # Login the user if authenticated else look for the
+        # other authentication sources if set in the config
+        if status:
+            # Login the user
+            status, msg = source.login()
+            if not status:
+                flash(gettext(msg), 'danger')
+                return flask.redirect(get_post_logout_redirect())
+
+            return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..18a376caf
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,91 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+    def __init__(self):
+        self.form = None
+        self.username = None
+        self.password = None
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+        return True
+
+    def login(self):
+        user = getattr(self.form, 'user',
+                       User.query.filter_by(username=self.username).first())
+
+        if user is None:
+            current_app.logger.exception(self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    @staticmethod
+    def messages(msg_key):
+        _default_msg = {
+            'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+            'LOGIN_FAILED': 'Login failed',
+            'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+            'PASSWORD_NOT_PROVIDED': 'Password not provided'
+        }
+        return _default_msg[msg_key] if msg_key in _default_msg else None
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def validate(self, form):
+        """User validation"""
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        # Flask security validation
+        return self.form.validate_on_submit()
+
+    def authenticate(self):
+        return True, None
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..f2c0ca7bc
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,164 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+
+    def authenticate(self):
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, msg = self.search_ldap_user()
+
+        if not status:
+            return status, msg
+
+        return self.__auto_create_user()
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+    def __auto_create_user(self):
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': '',
+                    'role': 2,
+                    'active': True,
+                    'newPassword': self.password,
+                    'confirmPassword': self.password
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                return True, None
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/authenticate/tests/__init__.py b/web/pgadmin/authenticate/tests/__init__.py
new file mode 100644
index 000000000..7af45b1b5
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/__init__.py
@@ -0,0 +1,8 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
diff --git a/web/pgadmin/authenticate/tests/test_ldap.py b/web/pgadmin/authenticate/tests/test_ldap.py
new file mode 100644
index 000000000..000f2d0b6
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/test_ldap.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.utils import server_utils as server_utils
+from regression import parent_node_dict
+import simplejson as json
+
+
+class DashboardGraphsTestCase(BaseTestGenerator):
+    """
+    This class validates the version in range functionality
+    by defining different version scenarios; where dict of
+    parameters describes the scenario appended by test name.
+    """
+
+    scenarios = [(
+        'TestCase for session_stats graph', dict(
+            did=-1,
+            chart_data={
+                'session_stats': ['Total', 'Active', 'Idle'],
+            }
+        ))
+    ]
+
+    def setUp(self):
+        pass
+
+    def getStatsUrl(self, sid=-1, did=-1, chart_names=''):
+        base_url = '/dashboard/dashboard_stats'
+        base_url = base_url + '/' + str(sid)
+        base_url += '/' + str(did) if did > 0 else ''
+        base_url += '?chart_names=' + chart_names
+        return base_url
+
+    def runTest(self):
+        self.server_id = parent_node_dict["server"][-1]["server_id"]
+        server_response = server_utils.connect_server(self, self.server_id)
+        if server_response["info"] == "Server connected.":
+
+            url = self.getStatsUrl(self.server_id, self.did,
+                                   ",".join(self.chart_data.keys()))
+            response = self.tester.get(url)
+            self.assertEquals(response.status_code, 200)
+
+            resp_data = json.loads(response.data)
+
+            # All requested charts received
+            self.assertEquals(len(resp_data.keys()),
+                              len(self.chart_data.keys()))
+
+            # All requested charts data received
+            for chart_name, chart_vals in self.chart_data.items():
+                self.assertEquals(set(resp_data[chart_name].keys()),
+                                  set(chart_vals))
+
+        else:
+            raise Exception("Error while connecting server to add the"
+                            " database.")
+
+    def tearDown(self):
+        pass
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..fc4005ed0 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -580,12 +580,18 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..f894623e3 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -149,6 +150,7 @@ window.onload = function(e){
                             {{ _('Change Password') }}
                         </a>
                     </li>
+                    {% endif %}
                     <li class="dropdown-divider"></li>
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..1aa8f7c33
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, "Done"])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..873e8c658 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -176,13 +176,20 @@ def user(uid):
 
     """
 
+    auth_only_internal = False
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        auth_only_internal = True
+
     if uid:
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_only_internal': auth_only_internal,
                }
     else:
         users = User.query.all()
@@ -190,9 +197,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_only_internal': auth_only_internal,
                                })
 
         res = users_data
@@ -215,11 +224,31 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+    else:
+        req_params = ('username', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +257,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+        if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+                current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+            username = new_data['email']
+            email = new_data['email']
+        else:
+            username = data['username']
+            email = getattr(new_data, 'email', '')
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=new_data['password'],
+                   auth_source=current_app.PGADMIN_EXTERNAL_AUTH_SOURCE)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +281,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,6 +373,7 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
                'role': usr.roles[0].id
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 64014863f..57275eef9 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -242,13 +242,27 @@ define([
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_only_internal: true,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            visible: function(m) {
+              if (m.get('auth_only_internal')) return false;
+              return true;
+            },
+            disabled: false,
+          },{
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +270,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('auth_only_internal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -344,7 +360,7 @@ define([
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_only_internal') === true && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-03-24 09:50  Khushboo Vashi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-03-24 09:50 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Please disregard my previous patch, attached the updated patch. :)


On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
[email protected]> wrote:

> Please disregard my previous patch, attached the updated patch.
>
> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi,
>>
>> Please find the attached updated patch.
>>
>>
>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>
>>> Hi
>>>
>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Hi Dave,
>>>>
>>>> Thanks for the review.
>>>>
>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>>>
>>>>> Hi
>>>>>
>>>>> 30 second read of the first version of the patch...
>>>>>
>>>>> - Please move the configuration into config.py. Users should never
>>>>> have to modify a distributed file (it messes up packaging). I don't see any
>>>>> reason to use a different file just for auth config.
>>>>>
>>>>> There are many settings for the LDAP, and in the future we will add
>>>> other external sources also, so I thought it would be better if we have
>>>> different file for the authentication.
>>>>
>>>
>>> Sure, but our config file is small compared to many. Splitting things
>>> out is more confusing for users. If they want to do that themselves of
>>> course, they can add a config_local.py file which includes other files as
>>> needed.
>>>
>> Fixed.
>>
>>>
>>>
>>>> - I think all config options should be prefixed with LDAP_ as we may
>>>>> have things like CERT_FILE for other purposes too.
>>>>>
>>>>> Sure.
>>>>
>>> Done.
>>
>>> - I don't see any test cases.
>>>>>
>>>>> I will think about this, as right now no idea how to write test cases
>>>> for this.
>>>>
>>>
>>> It should be fairly straightforward to write tests for some of the
>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>> probably need to add LDAP config options to test_config.json, and only if
>>> present, run the tests. That would probably need to support a list of LDAP
>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>> LDAP_STARTTLS, AD etc).
>>>
>>>
>> Done.
>>
>> Thanks,
>> Khushboo
>>
>>> Thanks.
>>>>>
>>>>> Thanks,
>>>> Khushboo
>>>>
>>>>>
>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Hi,
>>>>>>
>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>> Server mode.
>>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>>> configurations. The config settings are explained in this file in detail.
>>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>>> and connect with LDAP server with the valid user via login page.
>>>>>>
>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS,
>>>>>> I have used the default config of ldap3 without certificates.
>>>>>>
>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>> anything.
>>>>>>
>>>>>> Note: For the document update I will create the task and assign to
>>>>>> Nidhi for the same.
>>>>>>
>>>>>> Thanks,
>>>>>> Khushboo
>>>>>>
>>>>>
>>>>>
>>>>> --
>>>>> Dave Page
>>>>> Blog: http://pgsnake.blogspot.com
>>>>> Twitter: @pgsnake
>>>>>
>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>> The Enterprise PostgreSQL Company
>>>>>
>>>>
>>>
>>> --
>>> Dave Page
>>> Blog: http://pgsnake.blogspot.com
>>> Twitter: @pgsnake
>>>
>>> EnterpriseDB UK: http://www.enterprisedb.com
>>> The Enterprise PostgreSQL Company
>>>
>>


Attachments:

  [application/octet-stream] RM_2186_v1.patch (45.7K, 3-RM_2186_v1.patch)
  download | inline diff:
diff --git a/web/config.py b/web/config.py
index c26903310..55bed555f 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..2b46a49e6
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'INTERNAL',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..e71890ce0
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,96 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input data will be validated and authenticated.
+    """
+    form = _security.login_form()
+
+    # Loop through all the sources
+    for src in config.AUTHENTICATION_SOURCES:
+        source = get_auth_sources(src)
+
+        # Validate the user
+        if not source.validate(form):
+            for field in form.errors:
+                for error in form.errors[field]:
+                    flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+        # Authenticate the user
+        status, msg = source.authenticate()
+
+        # Login the user if authenticated else look for the
+        # other authentication sources if set in the config
+        if status:
+            # Login the user
+            status, msg = source.login()
+            if not status:
+                flash(gettext(msg), 'danger')
+                return flask.redirect(get_post_logout_redirect())
+
+            return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..18a376caf
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,91 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+    def __init__(self):
+        self.form = None
+        self.username = None
+        self.password = None
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+        return True
+
+    def login(self):
+        user = getattr(self.form, 'user',
+                       User.query.filter_by(username=self.username).first())
+
+        if user is None:
+            current_app.logger.exception(self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    @staticmethod
+    def messages(msg_key):
+        _default_msg = {
+            'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+            'LOGIN_FAILED': 'Login failed',
+            'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+            'PASSWORD_NOT_PROVIDED': 'Password not provided'
+        }
+        return _default_msg[msg_key] if msg_key in _default_msg else None
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def validate(self, form):
+        """User validation"""
+        self.form = form
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        # Flask security validation
+        return self.form.validate_on_submit()
+
+    def authenticate(self):
+        return True, None
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..4e91d5752
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,166 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+
+    def authenticate(self):
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, msg = self.search_ldap_user()
+
+        if not status:
+            return status, msg
+
+        return self.__auto_create_user()
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        return True, None
+
+    def __auto_create_user(self):
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': '',
+                    'role': 2,
+                    'active': True,
+                    'newPassword': self.password,
+                    'confirmPassword': self.password
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                return True, None
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/authenticate/tests/__init__.py b/web/pgadmin/authenticate/tests/__init__.py
new file mode 100644
index 000000000..7af45b1b5
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/__init__.py
@@ -0,0 +1,8 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
diff --git a/web/pgadmin/authenticate/tests/test_ldap.py b/web/pgadmin/authenticate/tests/test_ldap.py
new file mode 100644
index 000000000..000f2d0b6
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/test_ldap.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.utils import server_utils as server_utils
+from regression import parent_node_dict
+import simplejson as json
+
+
+class DashboardGraphsTestCase(BaseTestGenerator):
+    """
+    This class validates the version in range functionality
+    by defining different version scenarios; where dict of
+    parameters describes the scenario appended by test name.
+    """
+
+    scenarios = [(
+        'TestCase for session_stats graph', dict(
+            did=-1,
+            chart_data={
+                'session_stats': ['Total', 'Active', 'Idle'],
+            }
+        ))
+    ]
+
+    def setUp(self):
+        pass
+
+    def getStatsUrl(self, sid=-1, did=-1, chart_names=''):
+        base_url = '/dashboard/dashboard_stats'
+        base_url = base_url + '/' + str(sid)
+        base_url += '/' + str(did) if did > 0 else ''
+        base_url += '?chart_names=' + chart_names
+        return base_url
+
+    def runTest(self):
+        self.server_id = parent_node_dict["server"][-1]["server_id"]
+        server_response = server_utils.connect_server(self, self.server_id)
+        if server_response["info"] == "Server connected.":
+
+            url = self.getStatsUrl(self.server_id, self.did,
+                                   ",".join(self.chart_data.keys()))
+            response = self.tester.get(url)
+            self.assertEquals(response.status_code, 200)
+
+            resp_data = json.loads(response.data)
+
+            # All requested charts received
+            self.assertEquals(len(resp_data.keys()),
+                              len(self.chart_data.keys()))
+
+            # All requested charts data received
+            for chart_name, chart_vals in self.chart_data.items():
+                self.assertEquals(set(resp_data[chart_name].keys()),
+                                  set(chart_vals))
+
+        else:
+            raise Exception("Error while connecting server to add the"
+                            " database.")
+
+    def tearDown(self):
+        pass
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..fc4005ed0 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -580,12 +580,18 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..f894623e3 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -149,6 +150,7 @@ window.onload = function(e){
                             {{ _('Change Password') }}
                         </a>
                     </li>
+                    {% endif %}
                     <li class="dropdown-divider"></li>
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..1aa8f7c33
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, "Done"])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..873e8c658 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -176,13 +176,20 @@ def user(uid):
 
     """
 
+    auth_only_internal = False
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        auth_only_internal = True
+
     if uid:
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_only_internal': auth_only_internal,
                }
     else:
         users = User.query.all()
@@ -190,9 +197,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_only_internal': auth_only_internal,
                                })
 
         res = users_data
@@ -215,11 +224,31 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+            current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+    else:
+        req_params = ('username', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +257,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+        if current_app.PGADMIN_DEFAULT_AUTH_SOURCE == \
+                current_app.PGADMIN_EXTERNAL_AUTH_SOURCE:
+            username = new_data['email']
+            email = new_data['email']
+        else:
+            username = data['username']
+            email = getattr(new_data, 'email', '')
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=new_data['password'],
+                   auth_source=current_app.PGADMIN_EXTERNAL_AUTH_SOURCE)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +281,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,6 +373,7 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
                'role': usr.roles[0].id
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 2b1ed1727..f6cb96692 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -242,13 +242,27 @@ define([
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_only_internal: true,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            visible: function(m) {
+              if (m.get('auth_only_internal')) return false;
+              return true;
+            },
+            disabled: false,
+          },{
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +270,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('auth_only_internal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -344,7 +360,7 @@ define([
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_only_internal') === true && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-01 12:08  Khushboo Vashi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-04-01 12:08 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Hi,

Please find the attached updated patch which includes the review comments
given in the review meeting:

1. Do not store password for ldap user in sqlite database
2. Forgot Password : Give error to ldap users
3. User Management dialog changes
4. Authentication source display besides username / email after login

Thanks,
Khushboo


On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
[email protected]> wrote:

> Please disregard my previous patch, attached the updated patch. :)
>
>
> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
> [email protected]> wrote:
>
>> Please disregard my previous patch, attached the updated patch.
>>
>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi,
>>>
>>> Please find the attached updated patch.
>>>
>>>
>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>>
>>>> Hi
>>>>
>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Hi Dave,
>>>>>
>>>>> Thanks for the review.
>>>>>
>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>>>>
>>>>>> Hi
>>>>>>
>>>>>> 30 second read of the first version of the patch...
>>>>>>
>>>>>> - Please move the configuration into config.py. Users should never
>>>>>> have to modify a distributed file (it messes up packaging). I don't see any
>>>>>> reason to use a different file just for auth config.
>>>>>>
>>>>>> There are many settings for the LDAP, and in the future we will add
>>>>> other external sources also, so I thought it would be better if we have
>>>>> different file for the authentication.
>>>>>
>>>>
>>>> Sure, but our config file is small compared to many. Splitting things
>>>> out is more confusing for users. If they want to do that themselves of
>>>> course, they can add a config_local.py file which includes other files as
>>>> needed.
>>>>
>>> Fixed.
>>>
>>>>
>>>>
>>>>> - I think all config options should be prefixed with LDAP_ as we may
>>>>>> have things like CERT_FILE for other purposes too.
>>>>>>
>>>>>> Sure.
>>>>>
>>>> Done.
>>>
>>>> - I don't see any test cases.
>>>>>>
>>>>>> I will think about this, as right now no idea how to write test cases
>>>>> for this.
>>>>>
>>>>
>>>> It should be fairly straightforward to write tests for some of the
>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>> probably need to add LDAP config options to test_config.json, and only if
>>>> present, run the tests. That would probably need to support a list of LDAP
>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>> LDAP_STARTTLS, AD etc).
>>>>
>>>>
>>> Done.
>>>
>>> Thanks,
>>> Khushboo
>>>
>>>> Thanks.
>>>>>>
>>>>>> Thanks,
>>>>> Khushboo
>>>>>
>>>>>>
>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>> [email protected]> wrote:
>>>>>>
>>>>>>> Hi,
>>>>>>>
>>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>>> Server mode.
>>>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>>>> configurations. The config settings are explained in this file in detail.
>>>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>>>> and connect with LDAP server with the valid user via login page.
>>>>>>>
>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the TLS,
>>>>>>> I have used the default config of ldap3 without certificates.
>>>>>>>
>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>> anything.
>>>>>>>
>>>>>>> Note: For the document update I will create the task and assign to
>>>>>>> Nidhi for the same.
>>>>>>>
>>>>>>> Thanks,
>>>>>>> Khushboo
>>>>>>>
>>>>>>
>>>>>>
>>>>>> --
>>>>>> Dave Page
>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>> Twitter: @pgsnake
>>>>>>
>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>> The Enterprise PostgreSQL Company
>>>>>>
>>>>>
>>>>
>>>> --
>>>> Dave Page
>>>> Blog: http://pgsnake.blogspot.com
>>>> Twitter: @pgsnake
>>>>
>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>> The Enterprise PostgreSQL Company
>>>>
>>>


Attachments:

  [application/octet-stream] RM_2186_v2.patch (72.4K, 3-RM_2186_v2.patch)
  download | inline diff:
diff --git a/web/config.py b/web/config.py
index c26903310..55bed555f 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..89401686a
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'internal',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..63f524e23
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,156 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+import pickle
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security import current_user
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+from flask import session
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input will be validated and authenticated.
+    """
+    form = _security.login_form()
+    auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES)
+    session['_auth_source_manager_obj'] = None
+
+    # Validate the user
+    if not auth_obj.validate():
+        for field in form.errors:
+            for error in form.errors[field]:
+                flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+    # Authenticate the user
+    status, msg = auth_obj.authenticate()
+    if status:
+        # Login the user
+        status, msg = auth_obj.login()
+        if not status:
+            flash(gettext(msg), 'danger')
+            return flask.redirect(get_post_logout_redirect())
+
+        session['_auth_source_manager_obj'] = auth_obj.as_dict()
+        return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+class AuthSourceManager():
+    """This class will manage all the authentication sources.
+     """
+    def __init__(self, form, sources):
+        self.form = form
+        self.auth_sources = sources
+        self.source = None
+        self.source_friendly_name = None
+
+    def as_dict(self):
+        """
+        Returns the dictionary object representing this object.
+        """
+
+        res = dict()
+        res['source_friendly_name'] = self.source_friendly_name
+        res['auth_sources'] = self.auth_sources
+
+        return res
+
+    def set_source(self, source):
+        self.source = source
+
+    @property
+    def get_source(self):
+        return self.source
+
+    def set_source_friendly_name(self, name):
+        self.source_friendly_name = name
+
+    @property
+    def get_source_friendly_name(self):
+        return self.source_friendly_name
+
+    def validate(self):
+        """Validate through all the sources."""
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            if source.validate(self.form):
+                return True
+        return False
+
+    def authenticate(self):
+        """Authenticate through all the sources."""
+        status = False
+        msg = None
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            status, msg = source.authenticate(self.form)
+            if status:
+                self.set_source(source)
+                return status, msg
+        return status, msg
+
+    def login(self):
+        status, msg = self.source.login(self.form)
+        if status:
+            self.set_source_friendly_name(self.source.get_friendly_name())
+        return status, msg
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..8042239d6
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,96 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod, abstractproperty
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+
+    DEFAULT_MSG = {
+        'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+        'LOGIN_FAILED': 'Login failed',
+        'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+        'PASSWORD_NOT_PROVIDED': 'Password not provided'
+    }
+
+    @abstractproperty
+    def get_friendly_name(cls):
+        pass
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        return True
+
+    def login(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+
+        if user is None:
+            current_app.logger.exception(
+                self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    def messages(self, msg_key):
+        return self.DEFAULT_MSG[msg_key] if msg_key in self.DEFAULT_MSG\
+            else None
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def get_friendly_name(cls):
+        return gettext("internal")
+
+    def validate(self, form):
+        """User validation"""
+
+        # Flask security validation
+        return form.validate_on_submit()
+
+    def authenticate(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+        if user and user.is_authenticated and form.validate_on_submit():
+            return True, None
+        return False, self.messages('USER_DOES_NOT_EXIST')
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..be24ec193
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,180 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+    """Ldap Authentication Class"""
+
+    def get_friendly_name(self):
+        return gettext("ldap")
+
+    def authenticate(self, form):
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, user_email = self.search_ldap_user()
+
+        if not status:
+            return status, user_email
+
+        return self.__auto_create_user(user_email)
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except Exception as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        return True, None
+
+    def __auto_create_user(self, user_email):
+        """Add the ldap user to the internal SQLite database."""
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': user_email,
+                    'role': 2,
+                    'active': True,
+                    'source': 'ldap'
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            user_email = None
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                if 'mail' in entry:
+                    user_email = entry['mail'].value
+                return True, user_email
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/authenticate/tests/__init__.py b/web/pgadmin/authenticate/tests/__init__.py
new file mode 100644
index 000000000..7af45b1b5
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/__init__.py
@@ -0,0 +1,8 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
diff --git a/web/pgadmin/authenticate/tests/test_ldap.py b/web/pgadmin/authenticate/tests/test_ldap.py
new file mode 100644
index 000000000..000f2d0b6
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/test_ldap.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.utils import server_utils as server_utils
+from regression import parent_node_dict
+import simplejson as json
+
+
+class DashboardGraphsTestCase(BaseTestGenerator):
+    """
+    This class validates the version in range functionality
+    by defining different version scenarios; where dict of
+    parameters describes the scenario appended by test name.
+    """
+
+    scenarios = [(
+        'TestCase for session_stats graph', dict(
+            did=-1,
+            chart_data={
+                'session_stats': ['Total', 'Active', 'Idle'],
+            }
+        ))
+    ]
+
+    def setUp(self):
+        pass
+
+    def getStatsUrl(self, sid=-1, did=-1, chart_names=''):
+        base_url = '/dashboard/dashboard_stats'
+        base_url = base_url + '/' + str(sid)
+        base_url += '/' + str(did) if did > 0 else ''
+        base_url += '?chart_names=' + chart_names
+        return base_url
+
+    def runTest(self):
+        self.server_id = parent_node_dict["server"][-1]["server_id"]
+        server_response = server_utils.connect_server(self, self.server_id)
+        if server_response["info"] == "Server connected.":
+
+            url = self.getStatsUrl(self.server_id, self.did,
+                                   ",".join(self.chart_data.keys()))
+            response = self.tester.get(url)
+            self.assertEquals(response.status_code, 200)
+
+            resp_data = json.loads(response.data)
+
+            # All requested charts received
+            self.assertEquals(len(resp_data.keys()),
+                              len(self.chart_data.keys()))
+
+            # All requested charts data received
+            for chart_name, chart_vals in self.chart_data.items():
+                self.assertEquals(set(resp_data[chart_name].keys()),
+                                  set(chart_vals))
+
+        else:
+            raise Exception("Error while connecting server to add the"
+                            " database.")
+
+    def tearDown(self):
+        pass
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..09bdbc015 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -45,6 +45,7 @@ from pgadmin.browser.register_browser_preferences import \
 from pgadmin.utils.master_password import validate_master_password, \
     set_masterpass_check_text, cleanup_master_password, get_crypt_key, \
     set_crypt_key, process_masterpass_disabled
+from pgadmin.model import User
 
 try:
     import urllib.request as urlreq
@@ -580,12 +581,20 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
+        auth_source=session[
+            '_auth_source_manager_obj']['source_friendly_name'],
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
@@ -994,43 +1003,60 @@ if hasattr(config, 'SECURITY_RECOVERABLE') and config.SECURITY_RECOVERABLE:
             form = form_class()
 
         if form.validate_on_submit():
-            try:
-                send_reset_password_instructions(form.user)
-            except SOCKETErrorException as e:
-                # Handle socket errors which are not covered by SMTPExceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP Socket error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except (SMTPConnectError, SMTPResponseException,
-                    SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
-                    SMTPException, SMTPAuthenticationError, SMTPSenderRefused,
-                    SMTPRecipientsRefused) as e:
-
-                # Handle smtp specific exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except Exception as e:
-                # Handle other exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'Error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
+            # Check the Authentication source of the User
+            user = User.query.filter_by(
+                email=form.data['email'],
+                auth_source=current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+            ).first()
+
+            if user is None:
+                # If the user is not an internal user, raise the exception
+                flash(gettext('Your account is authenticated using an '
+                              'external {} source. '
+                              'Please contact the administrators of this '
+                              'service if you need to reset your password.'
+                              ).format(form.user.auth_source),
                       'danger')
                 has_error = True
+            if not has_error:
+                try:
+                    send_reset_password_instructions(form.user)
+                except SOCKETErrorException as e:
+                    # Handle socket errors which are not
+                    # covered by SMTPExceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP Socket error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except (SMTPConnectError, SMTPResponseException,
+                        SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
+                        SMTPException, SMTPAuthenticationError,
+                        SMTPSenderRefused, SMTPRecipientsRefused) as e:
+
+                    # Handle smtp specific exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except Exception as e:
+                    # Handle other exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'Error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
 
             if request.json is None and not has_error:
                 do_flash(*get_message('PASSWORD_RESET_REQUEST',
                                       email=form.user.email))
 
         if request.json and not has_error:
-            return _render_json(form, include_user=False)
+            return default_render_json(form, include_user=False)
 
         return _security.render_template(
             config_value('FORGOT_PASSWORD_TEMPLATE'),
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..b389b9574 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -150,6 +151,7 @@ window.onload = function(e){
                         </a>
                     </li>
                     <li class="dropdown-divider"></li>
+                    {% endif %}
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
                     <li class="dropdown-divider"></li>
diff --git a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
index 72ec97e59..eded8b68a 100644
--- a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
+++ b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
@@ -4,5 +4,5 @@ we will not associate our application with Gravatar module which will make
 'gravatar' filter unavailable in Jinja templates
 ###########################################################################}
 {% macro PREPARE_HTML() -%}
-'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} <span class="caret"></span>';
+'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} ({{auth_source}}) <span class="caret"></span>';
 {%- endmacro %}
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..90385242c
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, ''])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..7622645a0 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -74,7 +74,8 @@ class UserManagementModule(PgAdminModule):
             'user_management.roles', 'user_management.role',
             'user_management.update_user', 'user_management.delete_user',
             'user_management.create_user', 'user_management.users',
-            'user_management.user', current_app.login_manager.login_view
+            'user_management.user', current_app.login_manager.login_view,
+            'user_management.auth_sources', 'user_management.auth_sources'
         ]
 
 
@@ -100,7 +101,7 @@ def validate_user(data):
         else:
             raise Exception(_("Passwords do not match."))
 
-    if 'email' in data and data['email'] != "":
+    if 'email' in data and data['email'] and data['email'] != "":
         if email_filter.match(data['email']):
             new_data['email'] = data['email']
         else:
@@ -112,6 +113,12 @@ def validate_user(data):
     if 'active' in data and data['active'] != "":
         new_data['active'] = data['active']
 
+    if 'username' in data and data['username'] != "":
+        new_data['username'] = data['username']
+
+    if 'source' in data and data['source'] != "":
+        new_data['auth_source'] = data['source']
+
     return new_data
 
 
@@ -155,6 +162,7 @@ def current_user_info():
             else 'false',
             allow_save_tunnel_password='true'
             if config.ALLOW_SAVE_TUNNEL_PASSWORD else 'false',
+            auth_sources=config.AUTHENTICATION_SOURCES,
         ),
         status=200,
         mimetype="application/javascript"
@@ -180,9 +188,11 @@ def user(uid):
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'sources': u.auth_source
                }
     else:
         users = User.query.all()
@@ -190,9 +200,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'source': u.auth_source
                                })
 
         res = users_data
@@ -215,11 +227,29 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        return internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if 'source' in data and data['source'] != 'internal':
+        req_params = ('username', 'role', 'active', 'source')
+    else:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +258,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+
+        username = new_data['username'] if 'username' in new_data \
+            else new_data['email']
+        email = new_data['email'] if 'email' in new_data else None
+        password = new_data['password'] if 'password' in new_data else None
+        source = new_data['source'] if 'source' in new_data \
+            else current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=password,
+                   auth_source=source)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +282,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,9 +374,11 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
-               'role': usr.roles[0].id
+               'role': usr.roles[0].id,
+               'source': usr.auth_source
                }
 
         return ajax_response(
@@ -384,3 +423,17 @@ def role(rid):
         response=res,
         status=200
     )
+
+
[email protected](
+    '/auth_sources/', methods=['GET'], endpoint='auth_sources'
+)
+def auth_sources():
+    sources = []
+    for source in config.AUTHENTICATION_SOURCES:
+        sources.append({'label': source, 'value': source})
+
+    return ajax_response(
+        response=sources,
+        status=200
+    )
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 2b1ed1727..a88a2c450 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -9,12 +9,12 @@
 
 define([
   'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
-  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node',
+  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
   'pgadmin.user_management.current_user',
   'backgrid.select.all', 'backgrid.filter',
 ], function(
   gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
-  pgNode, userInfo
+  pgNode, pgBackform, userInfo
 ) {
 
   // if module is already initialized, refer to that.
@@ -24,6 +24,8 @@ define([
 
   var USERURL = url_for('user_management.users'),
     ROLEURL = url_for('user_management.roles'),
+    SOURCEURL = url_for('user_management.auth_sources'),
+    AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length  == 1 && userInfo['auth_sources'].includes('internal')) ? true : false,
     userFilter = function(collection) {
       return (new Backgrid.Extension.ClientSideFilter({
         collection: collection,
@@ -33,6 +35,41 @@ define([
       }));
     };
 
+  // Integer Cell for Columns Length and Precision
+  var PasswordDepCell = Backgrid.Extension.PasswordDepCell =
+    Backgrid.Extension.PasswordCell.extend({
+      initialize: function() {
+        Backgrid.Extension.PasswordCell.prototype.initialize.apply(this, arguments);
+        Backgrid.Extension.DependentCell.prototype.initialize.apply(this, arguments);
+      },
+      dependentChanged: function () {
+        this.$el.empty();
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+
+        this.delegateEvents();
+        return this;
+      },
+      render: function() {
+        Backgrid.NumberCell.prototype.render.apply(this, arguments);
+
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+        return this;
+      },
+      remove: Backgrid.Extension.DependentCell.prototype.remove,
+    });
+
   pgBrowser.UserManagement = {
     init: function() {
       if (this.initialized)
@@ -235,20 +272,67 @@ define([
     // Callback to draw User Management Dialog.
     show_users: function() {
       if (!userInfo['is_admin']) return;
-      var Roles = [];
+      var Roles = [],
+        Sources = [];
 
       var UserModel = pgBrowser.Node.Model.extend({
           idAttribute: 'id',
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            source: 'internal',
+            authOnlyInternal: AUTH_ONLY_INTERNAL,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            editable: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+          }, {
+            id: 'source',
+            label: gettext('Authentication Source'),
+            type: 'text',
+            control: 'Select2',
+            url: url_for('user_management.auth_sources'),
+            cellHeaderClasses: 'width_percent_30',
+            visible: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+            cell: 'Select2',
+            select2: {
+              allowClear: false,
+              openOnEnter: false,
+              first_empty: false,
+            },
+            options: function() {
+              return Sources;
+            },
+            editable: function(m) {
+              if (m instanceof Backbone.Collection) {
+                return true;
+              }
+              if (m.isNew() && !m.get('authOnlyInternal')) {
+                return true;
+              } else {
+                return false;
+              }
+            },
+          }, {
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +340,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('authOnlyInternal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -328,23 +414,38 @@ define([
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            deps: ['source'],
+            editable: function(m) {
+              if (m.get('source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }, {
             id: 'confirmPassword',
             label: gettext('Confirm password'),
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            editable: function(m) {
+              if (m.get('source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }],
           validate: function() {
             var errmsg = null,
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('source') == 'internal' && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
@@ -355,16 +456,6 @@ define([
               errmsg = gettext('Invalid email address: %s.',
                 this.get('email')
               );
-              this.errorModel.set('email', errmsg);
-              return errmsg;
-            } else if (!!this.get('email') && this.collection.where({
-              'email': this.get('email'),
-            }).length > 1) {
-
-              errmsg = gettext('The email address %s already exists.',
-                this.get('email')
-              );
-
               this.errorModel.set('email', errmsg);
               return errmsg;
             } else {
@@ -385,111 +476,113 @@ define([
               this.errorModel.unset('role');
             }
 
-            if (this.isNew()) {
-              // Password is compulsory for new user.
-              if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
-                  _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '')) {
-
-                errmsg = gettext('Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+            if (this.get('source') == 'internal') {
+              if (this.isNew()) {
+                // Password is compulsory for new user.
+                if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
+                    _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '')) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                this.get('newPassword').length < 6) {
+                  errmsg = gettext('Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-              }
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
-                  _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == '')) {
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                }
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
+                    _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == '')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
+                }
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
 
-            } else {
-              if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '') &&
-                ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == ''))) {
-
-                this.errorModel.unset('newPassword');
-                if (this.get('newPassword') == '') {
-                  this.set({
-                    'newPassword': undefined,
-                  });
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
                 }
 
-                this.errorModel.unset('confirmPassword');
-                if (this.get('confirmPassword') == '') {
-                  this.set({
-                    'confirmPassword': undefined,
-                  });
-                }
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                !this.get('newPassword') == '' &&
-                this.get('newPassword').length < 6) {
+              } else {
+                if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '') &&
+                  ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == ''))) {
+
+                  this.errorModel.unset('newPassword');
+                  if (this.get('newPassword') == '') {
+                    this.set({
+                      'newPassword': undefined,
+                    });
+                  }
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.unset('confirmPassword');
+                  if (this.get('confirmPassword') == '') {
+                    this.set({
+                      'confirmPassword': undefined,
+                    });
+                  }
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  !this.get('newPassword') == '' &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (_.isUndefined(this.get('confirmPassword')) ||
-                _.isNull(this.get('confirmPassword')) ||
-                this.get('confirmPassword') == '') {
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (_.isUndefined(this.get('confirmPassword')) ||
+                  _.isNull(this.get('confirmPassword')) ||
+                  this.get('confirmPassword') == '') {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-                this.errorModel.unset('confirmPassword');
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
+
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                  this.errorModel.unset('confirmPassword');
+                }
               }
             }
             return null;
@@ -716,7 +809,10 @@ define([
                   saveUser: function(m) {
                     var d = m.toJSON(true);
 
-                    if (m.isNew() && (!m.get('email') || !m.get('role') ||
+                    if(m.isNew() && m.get('authOnlyInternal') === false &&
+                     (!m.get('username') || !m.get('source') || !m.get('role')) ) {
+                      return false;
+                    } else if (m.isNew() && m.get('authOnlyInternal') === true &&  (!m.get('email') || !m.get('role') ||
                         !m.get('newPassword') || !m.get('confirmPassword') ||
                         m.get('newPassword') != m.get('confirmPassword'))) {
                       // New user model is valid but partially filled so return without saving.
@@ -741,7 +837,7 @@ define([
 
                           m.startNewSession();
                           alertify.success(gettext('User \'%s\' saved.',
-                            m.get('email')
+                            m.get('username')
                           ));
                         },
                         error: function(res, jqxhr) {
@@ -797,6 +893,23 @@ define([
                   }, 100);
                 });
 
+              $.ajax({
+                url: SOURCEURL,
+                method: 'GET',
+                async: false,
+              })
+                .done(function(res) {
+                  Sources = res;
+                })
+                .fail(function() {
+                  setTimeout(function() {
+                    alertify.alert(
+                      gettext('Error'),
+                      gettext('Cannot load user Sources.')
+                    );
+                  }, 100);
+                });
+
               var view = this.view = new Backgrid.Grid({
                 row: UserRow,
                 columns: gridSchema.columns,
diff --git a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
index cfcb77813..c6e210343 100644
--- a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
+++ b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
@@ -14,6 +14,7 @@ define('pgadmin.user_management.current_user', [], function() {
         'is_admin': {{ is_admin }},
         'name': '{{ name }}',
         'allow_save_password': {{ allow_save_password }},
-        'allow_save_tunnel_password': {{ allow_save_tunnel_password }}
+        'allow_save_tunnel_password': {{ allow_save_tunnel_password }},
+        'auth_sources': {{ auth_sources }}
     }
 });
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-02 08:36  Khushboo Vashi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-04-02 08:36 UTC (permalink / raw)
  To: Dave Page <[email protected]>; +Cc: pgadmin-hackers

Hi,

Resending the patch.
Missed the requirements.txt file in the previous patch.

Thanks,
Khushboo

On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Please find the attached updated patch which includes the review comments
> given in the review meeting:
>
> 1. Do not store password for ldap user in sqlite database
> 2. Forgot Password : Give error to ldap users
> 3. User Management dialog changes
> 4. Authentication source display besides username / email after login
>
> Thanks,
> Khushboo
>
>
> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
> [email protected]> wrote:
>
>> Please disregard my previous patch, attached the updated patch. :)
>>
>>
>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Please disregard my previous patch, attached the updated patch.
>>>
>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Hi,
>>>>
>>>> Please find the attached updated patch.
>>>>
>>>>
>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>>>
>>>>> Hi
>>>>>
>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Hi Dave,
>>>>>>
>>>>>> Thanks for the review.
>>>>>>
>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>>>>>
>>>>>>> Hi
>>>>>>>
>>>>>>> 30 second read of the first version of the patch...
>>>>>>>
>>>>>>> - Please move the configuration into config.py. Users should never
>>>>>>> have to modify a distributed file (it messes up packaging). I don't see any
>>>>>>> reason to use a different file just for auth config.
>>>>>>>
>>>>>>> There are many settings for the LDAP, and in the future we will add
>>>>>> other external sources also, so I thought it would be better if we have
>>>>>> different file for the authentication.
>>>>>>
>>>>>
>>>>> Sure, but our config file is small compared to many. Splitting things
>>>>> out is more confusing for users. If they want to do that themselves of
>>>>> course, they can add a config_local.py file which includes other files as
>>>>> needed.
>>>>>
>>>> Fixed.
>>>>
>>>>>
>>>>>
>>>>>> - I think all config options should be prefixed with LDAP_ as we may
>>>>>>> have things like CERT_FILE for other purposes too.
>>>>>>>
>>>>>>> Sure.
>>>>>>
>>>>> Done.
>>>>
>>>>> - I don't see any test cases.
>>>>>>>
>>>>>>> I will think about this, as right now no idea how to write test
>>>>>> cases for this.
>>>>>>
>>>>>
>>>>> It should be fairly straightforward to write tests for some of the
>>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>> LDAP_STARTTLS, AD etc).
>>>>>
>>>>>
>>>> Done.
>>>>
>>>> Thanks,
>>>> Khushboo
>>>>
>>>>> Thanks.
>>>>>>>
>>>>>>> Thanks,
>>>>>> Khushboo
>>>>>>
>>>>>>>
>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>> [email protected]> wrote:
>>>>>>>
>>>>>>>> Hi,
>>>>>>>>
>>>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>>>> Server mode.
>>>>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>>>>> configurations. The config settings are explained in this file in detail.
>>>>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>>>>> and connect with LDAP server with the valid user via login page.
>>>>>>>>
>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the
>>>>>>>> TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>
>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>> anything.
>>>>>>>>
>>>>>>>> Note: For the document update I will create the task and assign to
>>>>>>>> Nidhi for the same.
>>>>>>>>
>>>>>>>> Thanks,
>>>>>>>> Khushboo
>>>>>>>>
>>>>>>>
>>>>>>>
>>>>>>> --
>>>>>>> Dave Page
>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>> Twitter: @pgsnake
>>>>>>>
>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>
>>>>>>
>>>>>
>>>>> --
>>>>> Dave Page
>>>>> Blog: http://pgsnake.blogspot.com
>>>>> Twitter: @pgsnake
>>>>>
>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>> The Enterprise PostgreSQL Company
>>>>>
>>>>


Attachments:

  [application/octet-stream] RM_2186_v3.patch (72.6K, 3-RM_2186_v3.patch)
  download | inline diff:
diff --git a/requirements.txt b/requirements.txt
index f0cc71715..7f5f5f2cc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3>=2.5.1
diff --git a/web/config.py b/web/config.py
index f508f8fc6..3343f4b6d 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..89401686a
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'internal',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..b93b7cfd4 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,16 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..63f524e23
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,156 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+import pickle
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security import current_user
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+from flask import session
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input will be validated and authenticated.
+    """
+    form = _security.login_form()
+    auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES)
+    session['_auth_source_manager_obj'] = None
+
+    # Validate the user
+    if not auth_obj.validate():
+        for field in form.errors:
+            for error in form.errors[field]:
+                flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+    # Authenticate the user
+    status, msg = auth_obj.authenticate()
+    if status:
+        # Login the user
+        status, msg = auth_obj.login()
+        if not status:
+            flash(gettext(msg), 'danger')
+            return flask.redirect(get_post_logout_redirect())
+
+        session['_auth_source_manager_obj'] = auth_obj.as_dict()
+        return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+class AuthSourceManager():
+    """This class will manage all the authentication sources.
+     """
+    def __init__(self, form, sources):
+        self.form = form
+        self.auth_sources = sources
+        self.source = None
+        self.source_friendly_name = None
+
+    def as_dict(self):
+        """
+        Returns the dictionary object representing this object.
+        """
+
+        res = dict()
+        res['source_friendly_name'] = self.source_friendly_name
+        res['auth_sources'] = self.auth_sources
+
+        return res
+
+    def set_source(self, source):
+        self.source = source
+
+    @property
+    def get_source(self):
+        return self.source
+
+    def set_source_friendly_name(self, name):
+        self.source_friendly_name = name
+
+    @property
+    def get_source_friendly_name(self):
+        return self.source_friendly_name
+
+    def validate(self):
+        """Validate through all the sources."""
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            if source.validate(self.form):
+                return True
+        return False
+
+    def authenticate(self):
+        """Authenticate through all the sources."""
+        status = False
+        msg = None
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            status, msg = source.authenticate(self.form)
+            if status:
+                self.set_source(source)
+                return status, msg
+        return status, msg
+
+    def login(self):
+        status, msg = self.source.login(self.form)
+        if status:
+            self.set_source_friendly_name(self.source.get_friendly_name())
+        return status, msg
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..8042239d6
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,96 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod, abstractproperty
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+
+    DEFAULT_MSG = {
+        'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+        'LOGIN_FAILED': 'Login failed',
+        'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+        'PASSWORD_NOT_PROVIDED': 'Password not provided'
+    }
+
+    @abstractproperty
+    def get_friendly_name(cls):
+        pass
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        return True
+
+    def login(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+
+        if user is None:
+            current_app.logger.exception(
+                self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    def messages(self, msg_key):
+        return self.DEFAULT_MSG[msg_key] if msg_key in self.DEFAULT_MSG\
+            else None
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def get_friendly_name(cls):
+        return gettext("internal")
+
+    def validate(self, form):
+        """User validation"""
+
+        # Flask security validation
+        return form.validate_on_submit()
+
+    def authenticate(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+        if user and user.is_authenticated and form.validate_on_submit():
+            return True, None
+        return False, self.messages('USER_DOES_NOT_EXIST')
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..be24ec193
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,180 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+    """Ldap Authentication Class"""
+
+    def get_friendly_name(self):
+        return gettext("ldap")
+
+    def authenticate(self, form):
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, user_email = self.search_ldap_user()
+
+        if not status:
+            return status, user_email
+
+        return self.__auto_create_user(user_email)
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+            current_app.logger.exception(
+                "Error binding to the LDAP server: %s\n" % e)
+            return False, "Error binding to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except Exception as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        return True, None
+
+    def __auto_create_user(self, user_email):
+        """Add the ldap user to the internal SQLite database."""
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': user_email,
+                    'role': 2,
+                    'active': True,
+                    'source': 'ldap'
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            user_email = None
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                if 'mail' in entry:
+                    user_email = entry['mail'].value
+                return True, user_email
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/authenticate/tests/__init__.py b/web/pgadmin/authenticate/tests/__init__.py
new file mode 100644
index 000000000..7af45b1b5
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/__init__.py
@@ -0,0 +1,8 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
diff --git a/web/pgadmin/authenticate/tests/test_ldap.py b/web/pgadmin/authenticate/tests/test_ldap.py
new file mode 100644
index 000000000..000f2d0b6
--- /dev/null
+++ b/web/pgadmin/authenticate/tests/test_ldap.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.utils import server_utils as server_utils
+from regression import parent_node_dict
+import simplejson as json
+
+
+class DashboardGraphsTestCase(BaseTestGenerator):
+    """
+    This class validates the version in range functionality
+    by defining different version scenarios; where dict of
+    parameters describes the scenario appended by test name.
+    """
+
+    scenarios = [(
+        'TestCase for session_stats graph', dict(
+            did=-1,
+            chart_data={
+                'session_stats': ['Total', 'Active', 'Idle'],
+            }
+        ))
+    ]
+
+    def setUp(self):
+        pass
+
+    def getStatsUrl(self, sid=-1, did=-1, chart_names=''):
+        base_url = '/dashboard/dashboard_stats'
+        base_url = base_url + '/' + str(sid)
+        base_url += '/' + str(did) if did > 0 else ''
+        base_url += '?chart_names=' + chart_names
+        return base_url
+
+    def runTest(self):
+        self.server_id = parent_node_dict["server"][-1]["server_id"]
+        server_response = server_utils.connect_server(self, self.server_id)
+        if server_response["info"] == "Server connected.":
+
+            url = self.getStatsUrl(self.server_id, self.did,
+                                   ",".join(self.chart_data.keys()))
+            response = self.tester.get(url)
+            self.assertEquals(response.status_code, 200)
+
+            resp_data = json.loads(response.data)
+
+            # All requested charts received
+            self.assertEquals(len(resp_data.keys()),
+                              len(self.chart_data.keys()))
+
+            # All requested charts data received
+            for chart_name, chart_vals in self.chart_data.items():
+                self.assertEquals(set(resp_data[chart_name].keys()),
+                                  set(chart_vals))
+
+        else:
+            raise Exception("Error while connecting server to add the"
+                            " database.")
+
+    def tearDown(self):
+        pass
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..09bdbc015 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -45,6 +45,7 @@ from pgadmin.browser.register_browser_preferences import \
 from pgadmin.utils.master_password import validate_master_password, \
     set_masterpass_check_text, cleanup_master_password, get_crypt_key, \
     set_crypt_key, process_masterpass_disabled
+from pgadmin.model import User
 
 try:
     import urllib.request as urlreq
@@ -580,12 +581,20 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    if len(config.AUTHENTICATION_SOURCES) == 1\
+            and 'internal' in config.AUTHENTICATION_SOURCES:
+        auth_only_internal = True
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
+        auth_source=session[
+            '_auth_source_manager_obj']['source_friendly_name'],
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
@@ -994,43 +1003,60 @@ if hasattr(config, 'SECURITY_RECOVERABLE') and config.SECURITY_RECOVERABLE:
             form = form_class()
 
         if form.validate_on_submit():
-            try:
-                send_reset_password_instructions(form.user)
-            except SOCKETErrorException as e:
-                # Handle socket errors which are not covered by SMTPExceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP Socket error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except (SMTPConnectError, SMTPResponseException,
-                    SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
-                    SMTPException, SMTPAuthenticationError, SMTPSenderRefused,
-                    SMTPRecipientsRefused) as e:
-
-                # Handle smtp specific exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except Exception as e:
-                # Handle other exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'Error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
+            # Check the Authentication source of the User
+            user = User.query.filter_by(
+                email=form.data['email'],
+                auth_source=current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+            ).first()
+
+            if user is None:
+                # If the user is not an internal user, raise the exception
+                flash(gettext('Your account is authenticated using an '
+                              'external {} source. '
+                              'Please contact the administrators of this '
+                              'service if you need to reset your password.'
+                              ).format(form.user.auth_source),
                       'danger')
                 has_error = True
+            if not has_error:
+                try:
+                    send_reset_password_instructions(form.user)
+                except SOCKETErrorException as e:
+                    # Handle socket errors which are not
+                    # covered by SMTPExceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP Socket error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except (SMTPConnectError, SMTPResponseException,
+                        SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
+                        SMTPException, SMTPAuthenticationError,
+                        SMTPSenderRefused, SMTPRecipientsRefused) as e:
+
+                    # Handle smtp specific exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except Exception as e:
+                    # Handle other exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'Error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
 
             if request.json is None and not has_error:
                 do_flash(*get_message('PASSWORD_RESET_REQUEST',
                                       email=form.user.email))
 
         if request.json and not has_error:
-            return _render_json(form, include_user=False)
+            return default_render_json(form, include_user=False)
 
         return _security.render_template(
             config_value('FORGOT_PASSWORD_TEMPLATE'),
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..b389b9574 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -150,6 +151,7 @@ window.onload = function(e){
                         </a>
                     </li>
                     <li class="dropdown-divider"></li>
+                    {% endif %}
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
                     <li class="dropdown-divider"></li>
diff --git a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
index 72ec97e59..eded8b68a 100644
--- a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
+++ b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
@@ -4,5 +4,5 @@ we will not associate our application with Gravatar module which will make
 'gravatar' filter unavailable in Jinja templates
 ###########################################################################}
 {% macro PREPARE_HTML() -%}
-'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} <span class="caret"></span>';
+'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} ({{auth_source}}) <span class="caret"></span>';
 {%- endmacro %}
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..90385242c
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, ''])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..7622645a0 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -74,7 +74,8 @@ class UserManagementModule(PgAdminModule):
             'user_management.roles', 'user_management.role',
             'user_management.update_user', 'user_management.delete_user',
             'user_management.create_user', 'user_management.users',
-            'user_management.user', current_app.login_manager.login_view
+            'user_management.user', current_app.login_manager.login_view,
+            'user_management.auth_sources', 'user_management.auth_sources'
         ]
 
 
@@ -100,7 +101,7 @@ def validate_user(data):
         else:
             raise Exception(_("Passwords do not match."))
 
-    if 'email' in data and data['email'] != "":
+    if 'email' in data and data['email'] and data['email'] != "":
         if email_filter.match(data['email']):
             new_data['email'] = data['email']
         else:
@@ -112,6 +113,12 @@ def validate_user(data):
     if 'active' in data and data['active'] != "":
         new_data['active'] = data['active']
 
+    if 'username' in data and data['username'] != "":
+        new_data['username'] = data['username']
+
+    if 'source' in data and data['source'] != "":
+        new_data['auth_source'] = data['source']
+
     return new_data
 
 
@@ -155,6 +162,7 @@ def current_user_info():
             else 'false',
             allow_save_tunnel_password='true'
             if config.ALLOW_SAVE_TUNNEL_PASSWORD else 'false',
+            auth_sources=config.AUTHENTICATION_SOURCES,
         ),
         status=200,
         mimetype="application/javascript"
@@ -180,9 +188,11 @@ def user(uid):
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'sources': u.auth_source
                }
     else:
         users = User.query.all()
@@ -190,9 +200,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'source': u.auth_source
                                })
 
         res = users_data
@@ -215,11 +227,29 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        return internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if 'source' in data and data['source'] != 'internal':
+        req_params = ('username', 'role', 'active', 'source')
+    else:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +258,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+
+        username = new_data['username'] if 'username' in new_data \
+            else new_data['email']
+        email = new_data['email'] if 'email' in new_data else None
+        password = new_data['password'] if 'password' in new_data else None
+        source = new_data['source'] if 'source' in new_data \
+            else current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=password,
+                   auth_source=source)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +282,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,9 +374,11 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
-               'role': usr.roles[0].id
+               'role': usr.roles[0].id,
+               'source': usr.auth_source
                }
 
         return ajax_response(
@@ -384,3 +423,17 @@ def role(rid):
         response=res,
         status=200
     )
+
+
[email protected](
+    '/auth_sources/', methods=['GET'], endpoint='auth_sources'
+)
+def auth_sources():
+    sources = []
+    for source in config.AUTHENTICATION_SOURCES:
+        sources.append({'label': source, 'value': source})
+
+    return ajax_response(
+        response=sources,
+        status=200
+    )
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 2b1ed1727..a88a2c450 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -9,12 +9,12 @@
 
 define([
   'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
-  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node',
+  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
   'pgadmin.user_management.current_user',
   'backgrid.select.all', 'backgrid.filter',
 ], function(
   gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
-  pgNode, userInfo
+  pgNode, pgBackform, userInfo
 ) {
 
   // if module is already initialized, refer to that.
@@ -24,6 +24,8 @@ define([
 
   var USERURL = url_for('user_management.users'),
     ROLEURL = url_for('user_management.roles'),
+    SOURCEURL = url_for('user_management.auth_sources'),
+    AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length  == 1 && userInfo['auth_sources'].includes('internal')) ? true : false,
     userFilter = function(collection) {
       return (new Backgrid.Extension.ClientSideFilter({
         collection: collection,
@@ -33,6 +35,41 @@ define([
       }));
     };
 
+  // Integer Cell for Columns Length and Precision
+  var PasswordDepCell = Backgrid.Extension.PasswordDepCell =
+    Backgrid.Extension.PasswordCell.extend({
+      initialize: function() {
+        Backgrid.Extension.PasswordCell.prototype.initialize.apply(this, arguments);
+        Backgrid.Extension.DependentCell.prototype.initialize.apply(this, arguments);
+      },
+      dependentChanged: function () {
+        this.$el.empty();
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+
+        this.delegateEvents();
+        return this;
+      },
+      render: function() {
+        Backgrid.NumberCell.prototype.render.apply(this, arguments);
+
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+        return this;
+      },
+      remove: Backgrid.Extension.DependentCell.prototype.remove,
+    });
+
   pgBrowser.UserManagement = {
     init: function() {
       if (this.initialized)
@@ -235,20 +272,67 @@ define([
     // Callback to draw User Management Dialog.
     show_users: function() {
       if (!userInfo['is_admin']) return;
-      var Roles = [];
+      var Roles = [],
+        Sources = [];
 
       var UserModel = pgBrowser.Node.Model.extend({
           idAttribute: 'id',
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            source: 'internal',
+            authOnlyInternal: AUTH_ONLY_INTERNAL,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            editable: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+          }, {
+            id: 'source',
+            label: gettext('Authentication Source'),
+            type: 'text',
+            control: 'Select2',
+            url: url_for('user_management.auth_sources'),
+            cellHeaderClasses: 'width_percent_30',
+            visible: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+            cell: 'Select2',
+            select2: {
+              allowClear: false,
+              openOnEnter: false,
+              first_empty: false,
+            },
+            options: function() {
+              return Sources;
+            },
+            editable: function(m) {
+              if (m instanceof Backbone.Collection) {
+                return true;
+              }
+              if (m.isNew() && !m.get('authOnlyInternal')) {
+                return true;
+              } else {
+                return false;
+              }
+            },
+          }, {
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +340,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('authOnlyInternal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -328,23 +414,38 @@ define([
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            deps: ['source'],
+            editable: function(m) {
+              if (m.get('source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }, {
             id: 'confirmPassword',
             label: gettext('Confirm password'),
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            editable: function(m) {
+              if (m.get('source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }],
           validate: function() {
             var errmsg = null,
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('source') == 'internal' && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
@@ -355,16 +456,6 @@ define([
               errmsg = gettext('Invalid email address: %s.',
                 this.get('email')
               );
-              this.errorModel.set('email', errmsg);
-              return errmsg;
-            } else if (!!this.get('email') && this.collection.where({
-              'email': this.get('email'),
-            }).length > 1) {
-
-              errmsg = gettext('The email address %s already exists.',
-                this.get('email')
-              );
-
               this.errorModel.set('email', errmsg);
               return errmsg;
             } else {
@@ -385,111 +476,113 @@ define([
               this.errorModel.unset('role');
             }
 
-            if (this.isNew()) {
-              // Password is compulsory for new user.
-              if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
-                  _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '')) {
-
-                errmsg = gettext('Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+            if (this.get('source') == 'internal') {
+              if (this.isNew()) {
+                // Password is compulsory for new user.
+                if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
+                    _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '')) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                this.get('newPassword').length < 6) {
+                  errmsg = gettext('Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-              }
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
-                  _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == '')) {
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                }
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
+                    _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == '')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
+                }
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
 
-            } else {
-              if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '') &&
-                ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == ''))) {
-
-                this.errorModel.unset('newPassword');
-                if (this.get('newPassword') == '') {
-                  this.set({
-                    'newPassword': undefined,
-                  });
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
                 }
 
-                this.errorModel.unset('confirmPassword');
-                if (this.get('confirmPassword') == '') {
-                  this.set({
-                    'confirmPassword': undefined,
-                  });
-                }
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                !this.get('newPassword') == '' &&
-                this.get('newPassword').length < 6) {
+              } else {
+                if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '') &&
+                  ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == ''))) {
+
+                  this.errorModel.unset('newPassword');
+                  if (this.get('newPassword') == '') {
+                    this.set({
+                      'newPassword': undefined,
+                    });
+                  }
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.unset('confirmPassword');
+                  if (this.get('confirmPassword') == '') {
+                    this.set({
+                      'confirmPassword': undefined,
+                    });
+                  }
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  !this.get('newPassword') == '' &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (_.isUndefined(this.get('confirmPassword')) ||
-                _.isNull(this.get('confirmPassword')) ||
-                this.get('confirmPassword') == '') {
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (_.isUndefined(this.get('confirmPassword')) ||
+                  _.isNull(this.get('confirmPassword')) ||
+                  this.get('confirmPassword') == '') {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-                this.errorModel.unset('confirmPassword');
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
+
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                  this.errorModel.unset('confirmPassword');
+                }
               }
             }
             return null;
@@ -716,7 +809,10 @@ define([
                   saveUser: function(m) {
                     var d = m.toJSON(true);
 
-                    if (m.isNew() && (!m.get('email') || !m.get('role') ||
+                    if(m.isNew() && m.get('authOnlyInternal') === false &&
+                     (!m.get('username') || !m.get('source') || !m.get('role')) ) {
+                      return false;
+                    } else if (m.isNew() && m.get('authOnlyInternal') === true &&  (!m.get('email') || !m.get('role') ||
                         !m.get('newPassword') || !m.get('confirmPassword') ||
                         m.get('newPassword') != m.get('confirmPassword'))) {
                       // New user model is valid but partially filled so return without saving.
@@ -741,7 +837,7 @@ define([
 
                           m.startNewSession();
                           alertify.success(gettext('User \'%s\' saved.',
-                            m.get('email')
+                            m.get('username')
                           ));
                         },
                         error: function(res, jqxhr) {
@@ -797,6 +893,23 @@ define([
                   }, 100);
                 });
 
+              $.ajax({
+                url: SOURCEURL,
+                method: 'GET',
+                async: false,
+              })
+                .done(function(res) {
+                  Sources = res;
+                })
+                .fail(function() {
+                  setTimeout(function() {
+                    alertify.alert(
+                      gettext('Error'),
+                      gettext('Cannot load user Sources.')
+                    );
+                  }, 100);
+                });
+
               var view = this.view = new Backgrid.Grid({
                 row: UserRow,
                 columns: gridSchema.columns,
diff --git a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
index cfcb77813..c6e210343 100644
--- a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
+++ b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
@@ -14,6 +14,7 @@ define('pgadmin.user_management.current_user', [], function() {
         'is_admin': {{ is_admin }},
         'name': '{{ name }}',
         'allow_save_password': {{ allow_save_password }},
-        'allow_save_tunnel_password': {{ allow_save_tunnel_password }}
+        'allow_save_tunnel_password': {{ allow_save_tunnel_password }},
+        'auth_sources': {{ auth_sources }}
     }
 });
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-02 11:25  Akshay Joshi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Akshay Joshi @ 2020-04-02 11:25 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; +Cc: Dave Page <[email protected]>; pgadmin-hackers

Hi Khushboo

Following are the initial review comments (GUI):

*Desktop Mode: *

   - KeyError: '_auth_source_manager_obj' in desktop mode. (*Note* error
   occurs when the patch has applied and server mode is False.)

*Server Mode:*

AUTHENTICATION_SOURCES = ['internal']


   - Try to add a new user with the same email address, it throws a
   unique key constraint error. Validation was there previously before saving
   it.

AUTHENTICATION_SOURCES = ['internal', 'ldap']

   - Try to add a new user with the same email address, it throws
   unique key constraint error which should not it may possible that the user
   has the same email address for internal and ldap.

AUTHENTICATION_SOURCES = ['ldap']

   - If ipAddress or Port is not set in the configuration file then browser
   showing the following data, it should be shown proper error message on the
   login page
      - {"success":0,"errormsg":"Port could not be cast to integer value as
      '<port>'","info":"","result":null,"data":null}
   - If IP address and port is provided but it is wrong, it shows the
   following error, can we make a generic error message? Also clicking on the
   Close button on that error message is not working.
   [image: Screenshot 2020-04-02 at 4.23.55 PM.png]
   - IP address and port of LDAP server are correct, give wrong user name
   and password, it shows error "Error binding to the LDAP Server: None".
   Please correct the appropriate error message.
   - All the configuration parameter is correct and tries to log in on LDAP
   server using username (*not email address*) and password got following
   error:

current_user.email.split('@')[0] if config.SERVER_MODE is True
AttributeError: 'NoneType' object has no attribute 'split'.

Not able to test due to the above error. Please fix and resend the patch.

On Thu, Apr 2, 2020 at 2:06 PM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Resending the patch.
> Missed the requirements.txt file in the previous patch.
>
> Thanks,
> Khushboo
>
> On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi,
>>
>> Please find the attached updated patch which includes the review comments
>> given in the review meeting:
>>
>> 1. Do not store password for ldap user in sqlite database
>> 2. Forgot Password : Give error to ldap users
>> 3. User Management dialog changes
>> 4. Authentication source display besides username / email after login
>>
>> Thanks,
>> Khushboo
>>
>>
>> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Please disregard my previous patch, attached the updated patch. :)
>>>
>>>
>>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Please disregard my previous patch, attached the updated patch.
>>>>
>>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Hi,
>>>>>
>>>>> Please find the attached updated patch.
>>>>>
>>>>>
>>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>>>>
>>>>>> Hi
>>>>>>
>>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>>> [email protected]> wrote:
>>>>>>
>>>>>>> Hi Dave,
>>>>>>>
>>>>>>> Thanks for the review.
>>>>>>>
>>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]> wrote:
>>>>>>>
>>>>>>>> Hi
>>>>>>>>
>>>>>>>> 30 second read of the first version of the patch...
>>>>>>>>
>>>>>>>> - Please move the configuration into config.py. Users should never
>>>>>>>> have to modify a distributed file (it messes up packaging). I don't see any
>>>>>>>> reason to use a different file just for auth config.
>>>>>>>>
>>>>>>>> There are many settings for the LDAP, and in the future we will add
>>>>>>> other external sources also, so I thought it would be better if we have
>>>>>>> different file for the authentication.
>>>>>>>
>>>>>>
>>>>>> Sure, but our config file is small compared to many. Splitting things
>>>>>> out is more confusing for users. If they want to do that themselves of
>>>>>> course, they can add a config_local.py file which includes other files as
>>>>>> needed.
>>>>>>
>>>>> Fixed.
>>>>>
>>>>>>
>>>>>>
>>>>>>> - I think all config options should be prefixed with LDAP_ as we may
>>>>>>>> have things like CERT_FILE for other purposes too.
>>>>>>>>
>>>>>>>> Sure.
>>>>>>>
>>>>>> Done.
>>>>>
>>>>>> - I don't see any test cases.
>>>>>>>>
>>>>>>>> I will think about this, as right now no idea how to write test
>>>>>>> cases for this.
>>>>>>>
>>>>>>
>>>>>> It should be fairly straightforward to write tests for some of the
>>>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>>> LDAP_STARTTLS, AD etc).
>>>>>>
>>>>>>
>>>>> Done.
>>>>>
>>>>> Thanks,
>>>>> Khushboo
>>>>>
>>>>>> Thanks.
>>>>>>>>
>>>>>>>> Thanks,
>>>>>>> Khushboo
>>>>>>>
>>>>>>>>
>>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>>> [email protected]> wrote:
>>>>>>>>
>>>>>>>>> Hi,
>>>>>>>>>
>>>>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>>>>> Server mode.
>>>>>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>>>>>> configurations. The config settings are explained in this file in detail.
>>>>>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>>>>>> and connect with LDAP server with the valid user via login page.
>>>>>>>>>
>>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the
>>>>>>>>> TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>>
>>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>>> anything.
>>>>>>>>>
>>>>>>>>> Note: For the document update I will create the task and assign to
>>>>>>>>> Nidhi for the same.
>>>>>>>>>
>>>>>>>>> Thanks,
>>>>>>>>> Khushboo
>>>>>>>>>
>>>>>>>>
>>>>>>>>
>>>>>>>> --
>>>>>>>> Dave Page
>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>> Twitter: @pgsnake
>>>>>>>>
>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>
>>>>>>>
>>>>>>
>>>>>> --
>>>>>> Dave Page
>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>> Twitter: @pgsnake
>>>>>>
>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>> The Enterprise PostgreSQL Company
>>>>>>
>>>>>

-- 
*Thanks & Regards*
*Akshay Joshi*

*Sr. Software Architect*
*EnterpriseDB Software India Private Limited*
*Mobile: +91 976-788-8246*


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-02 13:30  Khushboo Vashi <[email protected]>
  parent: Akshay Joshi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-04-02 13:30 UTC (permalink / raw)
  To: Akshay Joshi <[email protected]>; +Cc: Dave Page <[email protected]>; pgadmin-hackers

Hi Akshay,

Please find the attached updated patch.

On Thu, Apr 2, 2020 at 4:55 PM Akshay Joshi <[email protected]>
wrote:

> Hi Khushboo
>
> Following are the initial review comments (GUI):
>
> *Desktop Mode: *
>
>    - KeyError: '_auth_source_manager_obj' in desktop mode. (*Note* error
>    occurs when the patch has applied and server mode is False.)
>
> Fixed.

> *Server Mode:*
>
> AUTHENTICATION_SOURCES = ['internal']
>
>
>    - Try to add a new user with the same email address, it throws a
>    unique key constraint error. Validation was there previously before saving
>    it.
>
> Fixed.

> AUTHENTICATION_SOURCES = ['internal', 'ldap']
>
>    - Try to add a new user with the same email address, it throws
>    unique key constraint error which should not it may possible that the user
>    has the same email address for internal and ldap.
>
> If the source is internal, it will not allow but with ldap, we can add the
user with the same email id.

> AUTHENTICATION_SOURCES = ['ldap']
>
>    - If ipAddress or Port is not set in the configuration file then
>    browser showing the following data, it should be shown proper error message
>    on the login page
>       - {"success":0,"errormsg":"Port could not be cast to integer value
>       as '<port>'","info":"","result":null,"data":null}
>
> Done

>
>    - If IP address and port is provided but it is wrong, it shows the
>    following error, can we make a generic error message? Also clicking on the
>    Close button on that error message is not working.
>    [image: Screenshot 2020-04-02 at 4.23.55 PM.png]
>
> I will look into the close button issue as it is an existing issue.

>
>    -
>    - IP address and port of LDAP server are correct, give wrong user name
>    and password, it shows error "Error binding to the LDAP Server: None".
>    Please correct the appropriate error message.
>
> Fixed.

>
>    - All the configuration parameter is correct and tries to log in on
>    LDAP server using username (*not email address*) and password got following
>    error:
>
> current_user.email.split('@')[0] if config.SERVER_MODE is True
> AttributeError: 'NoneType' object has no attribute 'split'.
>
> Fixed.

> Not able to test due to the above error. Please fix and resend the patch.
>

Thanks,
Khushboo

Thanks,
Khushboo

>
> On Thu, Apr 2, 2020 at 2:06 PM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi,
>>
>> Resending the patch.
>> Missed the requirements.txt file in the previous patch.
>>
>> Thanks,
>> Khushboo
>>
>> On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi,
>>>
>>> Please find the attached updated patch which includes the review
>>> comments given in the review meeting:
>>>
>>> 1. Do not store password for ldap user in sqlite database
>>> 2. Forgot Password : Give error to ldap users
>>> 3. User Management dialog changes
>>> 4. Authentication source display besides username / email after login
>>>
>>> Thanks,
>>> Khushboo
>>>
>>>
>>> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Please disregard my previous patch, attached the updated patch. :)
>>>>
>>>>
>>>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Please disregard my previous patch, attached the updated patch.
>>>>>
>>>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Hi,
>>>>>>
>>>>>> Please find the attached updated patch.
>>>>>>
>>>>>>
>>>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>>>>>
>>>>>>> Hi
>>>>>>>
>>>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>>>> [email protected]> wrote:
>>>>>>>
>>>>>>>> Hi Dave,
>>>>>>>>
>>>>>>>> Thanks for the review.
>>>>>>>>
>>>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]>
>>>>>>>> wrote:
>>>>>>>>
>>>>>>>>> Hi
>>>>>>>>>
>>>>>>>>> 30 second read of the first version of the patch...
>>>>>>>>>
>>>>>>>>> - Please move the configuration into config.py. Users should never
>>>>>>>>> have to modify a distributed file (it messes up packaging). I don't see any
>>>>>>>>> reason to use a different file just for auth config.
>>>>>>>>>
>>>>>>>>> There are many settings for the LDAP, and in the future we will
>>>>>>>> add other external sources also, so I thought it would be better if we have
>>>>>>>> different file for the authentication.
>>>>>>>>
>>>>>>>
>>>>>>> Sure, but our config file is small compared to many. Splitting
>>>>>>> things out is more confusing for users. If they want to do that themselves
>>>>>>> of course, they can add a config_local.py file which includes other files
>>>>>>> as needed.
>>>>>>>
>>>>>> Fixed.
>>>>>>
>>>>>>>
>>>>>>>
>>>>>>>> - I think all config options should be prefixed with LDAP_ as we
>>>>>>>>> may have things like CERT_FILE for other purposes too.
>>>>>>>>>
>>>>>>>>> Sure.
>>>>>>>>
>>>>>>> Done.
>>>>>>
>>>>>>> - I don't see any test cases.
>>>>>>>>>
>>>>>>>>> I will think about this, as right now no idea how to write test
>>>>>>>> cases for this.
>>>>>>>>
>>>>>>>
>>>>>>> It should be fairly straightforward to write tests for some of the
>>>>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>>>> LDAP_STARTTLS, AD etc).
>>>>>>>
>>>>>>>
>>>>>> Done.
>>>>>>
>>>>>> Thanks,
>>>>>> Khushboo
>>>>>>
>>>>>>> Thanks.
>>>>>>>>>
>>>>>>>>> Thanks,
>>>>>>>> Khushboo
>>>>>>>>
>>>>>>>>>
>>>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>>>> [email protected]> wrote:
>>>>>>>>>
>>>>>>>>>> Hi,
>>>>>>>>>>
>>>>>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>>>>>> Server mode.
>>>>>>>>>> To test the patch, config_auth.py needs to be configured for LDAP
>>>>>>>>>> configurations. The config settings are explained in this file in detail.
>>>>>>>>>> After configuring the parameters, start the pgadmin server in Server mode
>>>>>>>>>> and connect with LDAP server with the valid user via login page.
>>>>>>>>>>
>>>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the
>>>>>>>>>> TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>>>
>>>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>>>> anything.
>>>>>>>>>>
>>>>>>>>>> Note: For the document update I will create the task and assign
>>>>>>>>>> to Nidhi for the same.
>>>>>>>>>>
>>>>>>>>>> Thanks,
>>>>>>>>>> Khushboo
>>>>>>>>>>
>>>>>>>>>
>>>>>>>>>
>>>>>>>>> --
>>>>>>>>> Dave Page
>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>
>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>
>>>>>>>>
>>>>>>>
>>>>>>> --
>>>>>>> Dave Page
>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>> Twitter: @pgsnake
>>>>>>>
>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>
>>>>>>
>
> --
> *Thanks & Regards*
> *Akshay Joshi*
>
> *Sr. Software Architect*
> *EnterpriseDB Software India Private Limited*
> *Mobile: +91 976-788-8246*
>


Attachments:

  [application/octet-stream] RM_2186_v4.patch (70.3K, 3-RM_2186_v4.patch)
  download | inline diff:
diff --git a/requirements.txt b/requirements.txt
index f0cc71715..7f5f5f2cc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3>=2.5.1
diff --git a/web/config.py b/web/config.py
index f508f8fc6..3343f4b6d 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..89401686a
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'internal',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..45d33b72d 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,18 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_SUPPORTED_AUTH_SOURCE = ['internal', 'ldap']
+
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..63f524e23
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,156 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+import pickle
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security import current_user
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+from flask import session
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input will be validated and authenticated.
+    """
+    form = _security.login_form()
+    auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES)
+    session['_auth_source_manager_obj'] = None
+
+    # Validate the user
+    if not auth_obj.validate():
+        for field in form.errors:
+            for error in form.errors[field]:
+                flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+    # Authenticate the user
+    status, msg = auth_obj.authenticate()
+    if status:
+        # Login the user
+        status, msg = auth_obj.login()
+        if not status:
+            flash(gettext(msg), 'danger')
+            return flask.redirect(get_post_logout_redirect())
+
+        session['_auth_source_manager_obj'] = auth_obj.as_dict()
+        return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+class AuthSourceManager():
+    """This class will manage all the authentication sources.
+     """
+    def __init__(self, form, sources):
+        self.form = form
+        self.auth_sources = sources
+        self.source = None
+        self.source_friendly_name = None
+
+    def as_dict(self):
+        """
+        Returns the dictionary object representing this object.
+        """
+
+        res = dict()
+        res['source_friendly_name'] = self.source_friendly_name
+        res['auth_sources'] = self.auth_sources
+
+        return res
+
+    def set_source(self, source):
+        self.source = source
+
+    @property
+    def get_source(self):
+        return self.source
+
+    def set_source_friendly_name(self, name):
+        self.source_friendly_name = name
+
+    @property
+    def get_source_friendly_name(self):
+        return self.source_friendly_name
+
+    def validate(self):
+        """Validate through all the sources."""
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            if source.validate(self.form):
+                return True
+        return False
+
+    def authenticate(self):
+        """Authenticate through all the sources."""
+        status = False
+        msg = None
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            status, msg = source.authenticate(self.form)
+            if status:
+                self.set_source(source)
+                return status, msg
+        return status, msg
+
+    def login(self):
+        status, msg = self.source.login(self.form)
+        if status:
+            self.set_source_friendly_name(self.source.get_friendly_name())
+        return status, msg
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..62032f4e2
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,98 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod, abstractproperty
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+
+    DEFAULT_MSG = {
+        'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+        'LOGIN_FAILED': 'Login failed',
+        'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+        'PASSWORD_NOT_PROVIDED': 'Password not provided'
+    }
+
+    @abstractproperty
+    def get_friendly_name(cls):
+        pass
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        return True
+
+    def login(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user', None)
+
+        if user is None:
+            user = User.query.filter_by(username=username).first()
+
+        if user is None:
+            current_app.logger.exception(
+                self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    def messages(self, msg_key):
+        return self.DEFAULT_MSG[msg_key] if msg_key in self.DEFAULT_MSG\
+            else None
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def get_friendly_name(cls):
+        return gettext("internal")
+
+    def validate(self, form):
+        """User validation"""
+
+        # Flask security validation
+        return form.validate_on_submit()
+
+    def authenticate(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+        if user and user.is_authenticated and form.validate_on_submit():
+            return True, None
+        return False, self.messages('USER_DOES_NOT_EXIST')
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..1e83e2b62
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,183 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+    """Ldap Authentication Class"""
+
+    def get_friendly_name(self):
+        return gettext("ldap")
+
+    def authenticate(self, form):
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, user_email = self.search_ldap_user()
+
+        if not status:
+            return status, user_email
+
+        return self.__auto_create_user(user_email)
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+
+        if type(uri) == str:
+            return False, "LDAP configuration error: Set the proper LDAP URI."
+
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        # Create the server object
+        server = Server(uri.hostname,
+                        port=uri.port,
+                        use_ssl=(uri.scheme == 'ldaps'),
+                        get_info=ALL,
+                        tls=tls,
+                        connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+            current_app.logger.exception(
+                "Error binding to the LDAP server.")
+            return False, "Error binding to the LDAP server."
+        except Exception as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        return True, None
+
+    def __auto_create_user(self, user_email):
+        """Add the ldap user to the internal SQLite database."""
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': user_email,
+                    'role': 2,
+                    'active': True,
+                    'auth_source': 'ldap'
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            user_email = None
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                if 'mail' in entry:
+                    user_email = entry['mail'].value
+                return True, user_email
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..862490820 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -45,6 +45,7 @@ from pgadmin.browser.register_browser_preferences import \
 from pgadmin.utils.master_password import validate_master_password, \
     set_masterpass_check_text, cleanup_master_password, get_crypt_key, \
     set_crypt_key, process_masterpass_disabled
+from pgadmin.model import User
 
 try:
     import urllib.request as urlreq
@@ -580,12 +581,24 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    auth_source = []
+
+    if config.SERVER_MODE:
+        if len(config.AUTHENTICATION_SOURCES) == 1\
+                and 'internal' in config.AUTHENTICATION_SOURCES:
+            auth_only_internal = True
+        auth_source = session['_auth_source_manager_obj'][
+            'source_friendly_name']
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
+        auth_source=auth_source,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
@@ -994,43 +1007,60 @@ if hasattr(config, 'SECURITY_RECOVERABLE') and config.SECURITY_RECOVERABLE:
             form = form_class()
 
         if form.validate_on_submit():
-            try:
-                send_reset_password_instructions(form.user)
-            except SOCKETErrorException as e:
-                # Handle socket errors which are not covered by SMTPExceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP Socket error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except (SMTPConnectError, SMTPResponseException,
-                    SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
-                    SMTPException, SMTPAuthenticationError, SMTPSenderRefused,
-                    SMTPRecipientsRefused) as e:
-
-                # Handle smtp specific exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except Exception as e:
-                # Handle other exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'Error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
+            # Check the Authentication source of the User
+            user = User.query.filter_by(
+                email=form.data['email'],
+                auth_source=current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+            ).first()
+
+            if user is None:
+                # If the user is not an internal user, raise the exception
+                flash(gettext('Your account is authenticated using an '
+                              'external {} source. '
+                              'Please contact the administrators of this '
+                              'service if you need to reset your password.'
+                              ).format(form.user.auth_source),
                       'danger')
                 has_error = True
+            if not has_error:
+                try:
+                    send_reset_password_instructions(form.user)
+                except SOCKETErrorException as e:
+                    # Handle socket errors which are not
+                    # covered by SMTPExceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP Socket error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except (SMTPConnectError, SMTPResponseException,
+                        SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
+                        SMTPException, SMTPAuthenticationError,
+                        SMTPSenderRefused, SMTPRecipientsRefused) as e:
+
+                    # Handle smtp specific exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except Exception as e:
+                    # Handle other exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'Error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
 
             if request.json is None and not has_error:
                 do_flash(*get_message('PASSWORD_RESET_REQUEST',
                                       email=form.user.email))
 
         if request.json and not has_error:
-            return _render_json(form, include_user=False)
+            return default_render_json(form, include_user=False)
 
         return _security.render_template(
             config_value('FORGOT_PASSWORD_TEMPLATE'),
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..b389b9574 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -150,6 +151,7 @@ window.onload = function(e){
                         </a>
                     </li>
                     <li class="dropdown-divider"></li>
+                    {% endif %}
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
                     <li class="dropdown-divider"></li>
diff --git a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
index 72ec97e59..eded8b68a 100644
--- a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
+++ b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
@@ -4,5 +4,5 @@ we will not associate our application with Gravatar module which will make
 'gravatar' filter unavailable in Jinja templates
 ###########################################################################}
 {% macro PREPARE_HTML() -%}
-'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} <span class="caret"></span>';
+'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} ({{auth_source}}) <span class="caret"></span>';
 {%- endmacro %}
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..2f59dfff6
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,88 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..90385242c
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, ''])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..4f1602cdc 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -74,7 +74,8 @@ class UserManagementModule(PgAdminModule):
             'user_management.roles', 'user_management.role',
             'user_management.update_user', 'user_management.delete_user',
             'user_management.create_user', 'user_management.users',
-            'user_management.user', current_app.login_manager.login_view
+            'user_management.user', current_app.login_manager.login_view,
+            'user_management.auth_sources', 'user_management.auth_sources'
         ]
 
 
@@ -100,7 +101,7 @@ def validate_user(data):
         else:
             raise Exception(_("Passwords do not match."))
 
-    if 'email' in data and data['email'] != "":
+    if 'email' in data and data['email'] and data['email'] != "":
         if email_filter.match(data['email']):
             new_data['email'] = data['email']
         else:
@@ -112,6 +113,12 @@ def validate_user(data):
     if 'active' in data and data['active'] != "":
         new_data['active'] = data['active']
 
+    if 'username' in data and data['username'] != "":
+        new_data['username'] = data['username']
+
+    if 'auth_source' in data and data['auth_source'] != "":
+        new_data['auth_source'] = data['auth_source']
+
     return new_data
 
 
@@ -140,6 +147,7 @@ def script():
 @pgCSRFProtect.exempt
 @login_required
 def current_user_info():
+
     return Response(
         response=render_template(
             "user_management/js/current_user.js",
@@ -148,13 +156,14 @@ def current_user_info():
             user_id=current_user.id,
             email=current_user.email,
             name=(
-                current_user.email.split('@')[0] if config.SERVER_MODE is True
+                current_user.username.split('@')[0] if config.SERVER_MODE is True
                 else 'postgres'
             ),
             allow_save_password='true' if config.ALLOW_SAVE_PASSWORD
             else 'false',
             allow_save_tunnel_password='true'
             if config.ALLOW_SAVE_TUNNEL_PASSWORD else 'false',
+            auth_sources=config.AUTHENTICATION_SOURCES,
         ),
         status=200,
         mimetype="application/javascript"
@@ -180,9 +189,11 @@ def user(uid):
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_source': u.auth_source
                }
     else:
         users = User.query.all()
@@ -190,9 +201,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_source': u.auth_source
                                })
 
         res = users_data
@@ -215,11 +228,29 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        return internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if 'auth_source' in data and data['auth_source'] != 'internal':
+        req_params = ('username', 'role', 'active', 'auth_source')
+    else:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +259,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+
+        username = new_data['username'] if 'username' in new_data \
+            else new_data['email']
+        email = new_data['email'] if 'email' in new_data else None
+        password = new_data['password'] if 'password' in new_data else None
+        auth_source = new_data['auth_source'] if 'auth_source' in new_data \
+            else current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=password,
+                   auth_source=auth_source)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +283,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,9 +375,11 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
-               'role': usr.roles[0].id
+               'role': usr.roles[0].id,
+               'auth_source': usr.auth_source
                }
 
         return ajax_response(
@@ -384,3 +424,17 @@ def role(rid):
         response=res,
         status=200
     )
+
+
[email protected](
+    '/auth_sources/', methods=['GET'], endpoint='auth_sources'
+)
+def auth_sources():
+    sources = []
+    for source in current_app.PGADMIN_SUPPORTED_AUTH_SOURCE:
+        sources.append({'label': source, 'value': source})
+
+    return ajax_response(
+        response=sources,
+        status=200
+    )
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 2b1ed1727..0e1108f52 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -9,12 +9,12 @@
 
 define([
   'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
-  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node',
+  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
   'pgadmin.user_management.current_user',
   'backgrid.select.all', 'backgrid.filter',
 ], function(
   gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
-  pgNode, userInfo
+  pgNode, pgBackform, userInfo
 ) {
 
   // if module is already initialized, refer to that.
@@ -24,6 +24,8 @@ define([
 
   var USERURL = url_for('user_management.users'),
     ROLEURL = url_for('user_management.roles'),
+    SOURCEURL = url_for('user_management.auth_sources'),
+    AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length  == 1 && userInfo['auth_sources'].includes('internal')) ? true : false,
     userFilter = function(collection) {
       return (new Backgrid.Extension.ClientSideFilter({
         collection: collection,
@@ -33,6 +35,41 @@ define([
       }));
     };
 
+  // Integer Cell for Columns Length and Precision
+  var PasswordDepCell = Backgrid.Extension.PasswordDepCell =
+    Backgrid.Extension.PasswordCell.extend({
+      initialize: function() {
+        Backgrid.Extension.PasswordCell.prototype.initialize.apply(this, arguments);
+        Backgrid.Extension.DependentCell.prototype.initialize.apply(this, arguments);
+      },
+      dependentChanged: function () {
+        this.$el.empty();
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+
+        this.delegateEvents();
+        return this;
+      },
+      render: function() {
+        Backgrid.NumberCell.prototype.render.apply(this, arguments);
+
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+        return this;
+      },
+      remove: Backgrid.Extension.DependentCell.prototype.remove,
+    });
+
   pgBrowser.UserManagement = {
     init: function() {
       if (this.initialized)
@@ -235,20 +272,67 @@ define([
     // Callback to draw User Management Dialog.
     show_users: function() {
       if (!userInfo['is_admin']) return;
-      var Roles = [];
+      var Roles = [],
+        Sources = [];
 
       var UserModel = pgBrowser.Node.Model.extend({
           idAttribute: 'id',
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_source: 'internal',
+            authOnlyInternal: AUTH_ONLY_INTERNAL,
           },
           schema: [{
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['id'],
+            editable: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+          }, {
+            id: 'auth_source',
+            label: gettext('Authentication Source'),
+            type: 'text',
+            control: 'Select2',
+            url: url_for('user_management.auth_sources'),
+            cellHeaderClasses: 'width_percent_30',
+            visible: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+            cell: 'Select2',
+            select2: {
+              allowClear: false,
+              openOnEnter: false,
+              first_empty: false,
+            },
+            options: function() {
+              return Sources;
+            },
+            editable: function(m) {
+              if (m instanceof Backbone.Collection) {
+                return true;
+              }
+              if (m.isNew() && !m.get('authOnlyInternal')) {
+                return true;
+              } else {
+                return false;
+              }
+            },
+          }, {
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +340,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('authOnlyInternal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -328,23 +414,38 @@ define([
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            deps: ['auth_source'],
+            editable: function(m) {
+              if (m.get('auth_source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }, {
             id: 'confirmPassword',
             label: gettext('Confirm password'),
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            editable: function(m) {
+              if (m.get('auth_source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }],
           validate: function() {
             var errmsg = null,
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_source') == 'internal' && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
@@ -358,9 +459,8 @@ define([
               this.errorModel.set('email', errmsg);
               return errmsg;
             } else if (!!this.get('email') && this.collection.where({
-              'email': this.get('email'),
+              'email': this.get('email'), 'auth_source': 'internal',
             }).length > 1) {
-
               errmsg = gettext('The email address %s already exists.',
                 this.get('email')
               );
@@ -385,111 +485,113 @@ define([
               this.errorModel.unset('role');
             }
 
-            if (this.isNew()) {
-              // Password is compulsory for new user.
-              if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
-                  _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '')) {
-
-                errmsg = gettext('Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+            if (this.get('auth_source') == 'internal') {
+              if (this.isNew()) {
+                // Password is compulsory for new user.
+                if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
+                    _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '')) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                this.get('newPassword').length < 6) {
+                  errmsg = gettext('Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-              }
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
-                  _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == '')) {
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                }
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
+                    _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == '')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
+                }
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
 
-            } else {
-              if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '') &&
-                ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == ''))) {
-
-                this.errorModel.unset('newPassword');
-                if (this.get('newPassword') == '') {
-                  this.set({
-                    'newPassword': undefined,
-                  });
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
                 }
 
-                this.errorModel.unset('confirmPassword');
-                if (this.get('confirmPassword') == '') {
-                  this.set({
-                    'confirmPassword': undefined,
-                  });
-                }
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                !this.get('newPassword') == '' &&
-                this.get('newPassword').length < 6) {
+              } else {
+                if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '') &&
+                  ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == ''))) {
+
+                  this.errorModel.unset('newPassword');
+                  if (this.get('newPassword') == '') {
+                    this.set({
+                      'newPassword': undefined,
+                    });
+                  }
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.unset('confirmPassword');
+                  if (this.get('confirmPassword') == '') {
+                    this.set({
+                      'confirmPassword': undefined,
+                    });
+                  }
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  !this.get('newPassword') == '' &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (_.isUndefined(this.get('confirmPassword')) ||
-                _.isNull(this.get('confirmPassword')) ||
-                this.get('confirmPassword') == '') {
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (_.isUndefined(this.get('confirmPassword')) ||
+                  _.isNull(this.get('confirmPassword')) ||
+                  this.get('confirmPassword') == '') {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-                this.errorModel.unset('confirmPassword');
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
+
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                  this.errorModel.unset('confirmPassword');
+                }
               }
             }
             return null;
@@ -716,7 +818,10 @@ define([
                   saveUser: function(m) {
                     var d = m.toJSON(true);
 
-                    if (m.isNew() && (!m.get('email') || !m.get('role') ||
+                    if(m.isNew() && m.get('authOnlyInternal') === false &&
+                     (!m.get('username') || !m.get('auth_source') || !m.get('role')) ) {
+                      return false;
+                    } else if (m.isNew() && m.get('authOnlyInternal') === true &&  (!m.get('email') || !m.get('role') ||
                         !m.get('newPassword') || !m.get('confirmPassword') ||
                         m.get('newPassword') != m.get('confirmPassword'))) {
                       // New user model is valid but partially filled so return without saving.
@@ -741,7 +846,7 @@ define([
 
                           m.startNewSession();
                           alertify.success(gettext('User \'%s\' saved.',
-                            m.get('email')
+                            m.get('username')
                           ));
                         },
                         error: function(res, jqxhr) {
@@ -797,6 +902,23 @@ define([
                   }, 100);
                 });
 
+              $.ajax({
+                url: SOURCEURL,
+                method: 'GET',
+                async: false,
+              })
+                .done(function(res) {
+                  Sources = res;
+                })
+                .fail(function() {
+                  setTimeout(function() {
+                    alertify.alert(
+                      gettext('Error'),
+                      gettext('Cannot load user Sources.')
+                    );
+                  }, 100);
+                });
+
               var view = this.view = new Backgrid.Grid({
                 row: UserRow,
                 columns: gridSchema.columns,
diff --git a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
index cfcb77813..c6e210343 100644
--- a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
+++ b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
@@ -14,6 +14,7 @@ define('pgadmin.user_management.current_user', [], function() {
         'is_admin': {{ is_admin }},
         'name': '{{ name }}',
         'allow_save_password': {{ allow_save_password }},
-        'allow_save_tunnel_password': {{ allow_save_tunnel_password }}
+        'allow_save_tunnel_password': {{ allow_save_tunnel_password }},
+        'auth_sources': {{ auth_sources }}
     }
 });
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-03 08:19  Akshay Joshi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Akshay Joshi @ 2020-04-03 08:19 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; Dave Page <[email protected]>; +Cc: pgadmin-hackers

Hi Khushboo

Some more review comments:

   - Fix one small PEP8 issue.
   - If ipAddress or Port is not set in the configuration file then browser
   showing the following data, it should be shown proper error message on the
   login page
      - {"success":0,"errormsg":"Port could not be cast to integer value as
      '<port>'","info":"","result":null,"data":null}
   - Disable the Username field in the User Management dialog if the
   authentication source is set to internal.
   - API Test cases are failing if LDAP related settings are not
   provided in the test_config.json file. If the configuration is not provided
   then LDAP tests should be skipped.

@Dave, I have tested and done the code review. Can you please do it once as
well, whenever Khushboo will fix and send the updated patch?


On Thu, Apr 2, 2020 at 7:00 PM Khushboo Vashi <
[email protected]> wrote:

> Hi Akshay,
>
> Please find the attached updated patch.
>
> On Thu, Apr 2, 2020 at 4:55 PM Akshay Joshi <[email protected]>
> wrote:
>
>> Hi Khushboo
>>
>> Following are the initial review comments (GUI):
>>
>> *Desktop Mode: *
>>
>>    - KeyError: '_auth_source_manager_obj' in desktop mode. (*Note* error
>>    occurs when the patch has applied and server mode is False.)
>>
>> Fixed.
>
>> *Server Mode:*
>>
>> AUTHENTICATION_SOURCES = ['internal']
>>
>>
>>    - Try to add a new user with the same email address, it throws a
>>    unique key constraint error. Validation was there previously before saving
>>    it.
>>
>> Fixed.
>
>> AUTHENTICATION_SOURCES = ['internal', 'ldap']
>>
>>    - Try to add a new user with the same email address, it throws
>>    unique key constraint error which should not it may possible that the user
>>    has the same email address for internal and ldap.
>>
>> If the source is internal, it will not allow but with ldap, we can add
> the user with the same email id.
>
>> AUTHENTICATION_SOURCES = ['ldap']
>>
>>    - If ipAddress or Port is not set in the configuration file then
>>    browser showing the following data, it should be shown proper error message
>>    on the login page
>>       - {"success":0,"errormsg":"Port could not be cast to integer value
>>       as '<port>'","info":"","result":null,"data":null}
>>
>> Done
>
>>
>>    - If IP address and port is provided but it is wrong, it shows the
>>    following error, can we make a generic error message? Also clicking on the
>>    Close button on that error message is not working.
>>    [image: Screenshot 2020-04-02 at 4.23.55 PM.png]
>>
>> I will look into the close button issue as it is an existing issue.
>
>>
>>    -
>>    - IP address and port of LDAP server are correct, give wrong user
>>    name and password, it shows error "Error binding to the LDAP Server: None".
>>    Please correct the appropriate error message.
>>
>> Fixed.
>
>>
>>    - All the configuration parameter is correct and tries to log in on
>>    LDAP server using username (*not email address*) and password got following
>>    error:
>>
>> current_user.email.split('@')[0] if config.SERVER_MODE is True
>> AttributeError: 'NoneType' object has no attribute 'split'.
>>
>> Fixed.
>
>> Not able to test due to the above error. Please fix and resend the patch.
>>
>
> Thanks,
> Khushboo
>
> Thanks,
> Khushboo
>
>>
>> On Thu, Apr 2, 2020 at 2:06 PM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi,
>>>
>>> Resending the patch.
>>> Missed the requirements.txt file in the previous patch.
>>>
>>> Thanks,
>>> Khushboo
>>>
>>> On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Hi,
>>>>
>>>> Please find the attached updated patch which includes the review
>>>> comments given in the review meeting:
>>>>
>>>> 1. Do not store password for ldap user in sqlite database
>>>> 2. Forgot Password : Give error to ldap users
>>>> 3. User Management dialog changes
>>>> 4. Authentication source display besides username / email after login
>>>>
>>>> Thanks,
>>>> Khushboo
>>>>
>>>>
>>>> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Please disregard my previous patch, attached the updated patch. :)
>>>>>
>>>>>
>>>>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Please disregard my previous patch, attached the updated patch.
>>>>>>
>>>>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>>>>> [email protected]> wrote:
>>>>>>
>>>>>>> Hi,
>>>>>>>
>>>>>>> Please find the attached updated patch.
>>>>>>>
>>>>>>>
>>>>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]> wrote:
>>>>>>>
>>>>>>>> Hi
>>>>>>>>
>>>>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>>>>> [email protected]> wrote:
>>>>>>>>
>>>>>>>>> Hi Dave,
>>>>>>>>>
>>>>>>>>> Thanks for the review.
>>>>>>>>>
>>>>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]>
>>>>>>>>> wrote:
>>>>>>>>>
>>>>>>>>>> Hi
>>>>>>>>>>
>>>>>>>>>> 30 second read of the first version of the patch...
>>>>>>>>>>
>>>>>>>>>> - Please move the configuration into config.py. Users should
>>>>>>>>>> never have to modify a distributed file (it messes up packaging). I don't
>>>>>>>>>> see any reason to use a different file just for auth config.
>>>>>>>>>>
>>>>>>>>>> There are many settings for the LDAP, and in the future we will
>>>>>>>>> add other external sources also, so I thought it would be better if we have
>>>>>>>>> different file for the authentication.
>>>>>>>>>
>>>>>>>>
>>>>>>>> Sure, but our config file is small compared to many. Splitting
>>>>>>>> things out is more confusing for users. If they want to do that themselves
>>>>>>>> of course, they can add a config_local.py file which includes other files
>>>>>>>> as needed.
>>>>>>>>
>>>>>>> Fixed.
>>>>>>>
>>>>>>>>
>>>>>>>>
>>>>>>>>> - I think all config options should be prefixed with LDAP_ as we
>>>>>>>>>> may have things like CERT_FILE for other purposes too.
>>>>>>>>>>
>>>>>>>>>> Sure.
>>>>>>>>>
>>>>>>>> Done.
>>>>>>>
>>>>>>>> - I don't see any test cases.
>>>>>>>>>>
>>>>>>>>>> I will think about this, as right now no idea how to write test
>>>>>>>>> cases for this.
>>>>>>>>>
>>>>>>>>
>>>>>>>> It should be fairly straightforward to write tests for some of the
>>>>>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>>>>> LDAP_STARTTLS, AD etc).
>>>>>>>>
>>>>>>>>
>>>>>>> Done.
>>>>>>>
>>>>>>> Thanks,
>>>>>>> Khushboo
>>>>>>>
>>>>>>>> Thanks.
>>>>>>>>>>
>>>>>>>>>> Thanks,
>>>>>>>>> Khushboo
>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>>>>> [email protected]> wrote:
>>>>>>>>>>
>>>>>>>>>>> Hi,
>>>>>>>>>>>
>>>>>>>>>>> Please find the attached patch to support LDAP Authentication in
>>>>>>>>>>> Server mode.
>>>>>>>>>>> To test the patch, config_auth.py needs to be configured for
>>>>>>>>>>> LDAP configurations. The config settings are explained in this file in
>>>>>>>>>>> detail. After configuring the parameters, start the pgadmin server in
>>>>>>>>>>> Server mode and connect with LDAP server with the valid user via login page.
>>>>>>>>>>>
>>>>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the
>>>>>>>>>>> TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>>>>
>>>>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>>>>> anything.
>>>>>>>>>>>
>>>>>>>>>>> Note: For the document update I will create the task and assign
>>>>>>>>>>> to Nidhi for the same.
>>>>>>>>>>>
>>>>>>>>>>> Thanks,
>>>>>>>>>>> Khushboo
>>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>> --
>>>>>>>>>> Dave Page
>>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>>
>>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>>
>>>>>>>>>
>>>>>>>>
>>>>>>>> --
>>>>>>>> Dave Page
>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>> Twitter: @pgsnake
>>>>>>>>
>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>
>>>>>>>
>>
>> --
>> *Thanks & Regards*
>> *Akshay Joshi*
>>
>> *Sr. Software Architect*
>> *EnterpriseDB Software India Private Limited*
>> *Mobile: +91 976-788-8246*
>>
>

-- 
*Thanks & Regards*
*Akshay Joshi*

*Sr. Software Architect*
*EnterpriseDB Software India Private Limited*
*Mobile: +91 976-788-8246*


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-03 09:07  Khushboo Vashi <[email protected]>
  parent: Akshay Joshi <[email protected]>
  0 siblings, 1 reply; 16+ messages in thread

From: Khushboo Vashi @ 2020-04-03 09:07 UTC (permalink / raw)
  To: Akshay Joshi <[email protected]>; +Cc: Dave Page <[email protected]>; pgadmin-hackers

Hi,

Please find the attached updated patch.

On Fri, Apr 3, 2020 at 1:50 PM Akshay Joshi <[email protected]>
wrote:

> Hi Khushboo
>
> Some more review comments:
>
>    - Fix one small PEP8 issue.
>
> Fixed.

>
>    - If ipAddress or Port is not set in the configuration file then
>    browser showing the following data, it should be shown proper error message
>    on the login page
>       - {"success":0,"errormsg":"Port could not be cast to integer value
>       as '<port>'","info":"","result":null,"data":null}
>
> Fixed.

>
>    - Disable the Username field in the User Management dialog if the
>    authentication source is set to internal.
>
> Done.

>
>    - API Test cases are failing if LDAP related settings are not
>    provided in the test_config.json file. If the configuration is not provided
>    then LDAP tests should be skipped.
>
> Fixed.

> @Dave, I have tested and done the code review. Can you please do it once
> as well, whenever Khushboo will fix and send the updated patch?
>
> Thanks,
Khushboo

>
> On Thu, Apr 2, 2020 at 7:00 PM Khushboo Vashi <
> [email protected]> wrote:
>
>> Hi Akshay,
>>
>> Please find the attached updated patch.
>>
>> On Thu, Apr 2, 2020 at 4:55 PM Akshay Joshi <
>> [email protected]> wrote:
>>
>>> Hi Khushboo
>>>
>>> Following are the initial review comments (GUI):
>>>
>>> *Desktop Mode: *
>>>
>>>    - KeyError: '_auth_source_manager_obj' in desktop mode. (*Note*
>>>    error occurs when the patch has applied and server mode is False.)
>>>
>>> Fixed.
>>
>>> *Server Mode:*
>>>
>>> AUTHENTICATION_SOURCES = ['internal']
>>>
>>>
>>>    - Try to add a new user with the same email address, it throws a
>>>    unique key constraint error. Validation was there previously before saving
>>>    it.
>>>
>>> Fixed.
>>
>>> AUTHENTICATION_SOURCES = ['internal', 'ldap']
>>>
>>>    - Try to add a new user with the same email address, it throws
>>>    unique key constraint error which should not it may possible that the user
>>>    has the same email address for internal and ldap.
>>>
>>> If the source is internal, it will not allow but with ldap, we can add
>> the user with the same email id.
>>
>>> AUTHENTICATION_SOURCES = ['ldap']
>>>
>>>    - If ipAddress or Port is not set in the configuration file then
>>>    browser showing the following data, it should be shown proper error message
>>>    on the login page
>>>       - {"success":0,"errormsg":"Port could not be cast to integer
>>>       value as '<port>'","info":"","result":null,"data":null}
>>>
>>> Done
>>
>>>
>>>    - If IP address and port is provided but it is wrong, it shows the
>>>    following error, can we make a generic error message? Also clicking on the
>>>    Close button on that error message is not working.
>>>    [image: Screenshot 2020-04-02 at 4.23.55 PM.png]
>>>
>>> I will look into the close button issue as it is an existing issue.
>>
>>>
>>>    -
>>>    - IP address and port of LDAP server are correct, give wrong user
>>>    name and password, it shows error "Error binding to the LDAP Server: None".
>>>    Please correct the appropriate error message.
>>>
>>> Fixed.
>>
>>>
>>>    - All the configuration parameter is correct and tries to log in on
>>>    LDAP server using username (*not email address*) and password got following
>>>    error:
>>>
>>> current_user.email.split('@')[0] if config.SERVER_MODE is True
>>> AttributeError: 'NoneType' object has no attribute 'split'.
>>>
>>> Fixed.
>>
>>> Not able to test due to the above error. Please fix and resend the patch.
>>>
>>
>> Thanks,
>> Khushboo
>>
>> Thanks,
>> Khushboo
>>
>>>
>>> On Thu, Apr 2, 2020 at 2:06 PM Khushboo Vashi <
>>> [email protected]> wrote:
>>>
>>>> Hi,
>>>>
>>>> Resending the patch.
>>>> Missed the requirements.txt file in the previous patch.
>>>>
>>>> Thanks,
>>>> Khushboo
>>>>
>>>> On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Hi,
>>>>>
>>>>> Please find the attached updated patch which includes the review
>>>>> comments given in the review meeting:
>>>>>
>>>>> 1. Do not store password for ldap user in sqlite database
>>>>> 2. Forgot Password : Give error to ldap users
>>>>> 3. User Management dialog changes
>>>>> 4. Authentication source display besides username / email after login
>>>>>
>>>>> Thanks,
>>>>> Khushboo
>>>>>
>>>>>
>>>>> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Please disregard my previous patch, attached the updated patch. :)
>>>>>>
>>>>>>
>>>>>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>>>>>> [email protected]> wrote:
>>>>>>
>>>>>>> Please disregard my previous patch, attached the updated patch.
>>>>>>>
>>>>>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>>>>>> [email protected]> wrote:
>>>>>>>
>>>>>>>> Hi,
>>>>>>>>
>>>>>>>> Please find the attached updated patch.
>>>>>>>>
>>>>>>>>
>>>>>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]>
>>>>>>>> wrote:
>>>>>>>>
>>>>>>>>> Hi
>>>>>>>>>
>>>>>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>>>>>> [email protected]> wrote:
>>>>>>>>>
>>>>>>>>>> Hi Dave,
>>>>>>>>>>
>>>>>>>>>> Thanks for the review.
>>>>>>>>>>
>>>>>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]>
>>>>>>>>>> wrote:
>>>>>>>>>>
>>>>>>>>>>> Hi
>>>>>>>>>>>
>>>>>>>>>>> 30 second read of the first version of the patch...
>>>>>>>>>>>
>>>>>>>>>>> - Please move the configuration into config.py. Users should
>>>>>>>>>>> never have to modify a distributed file (it messes up packaging). I don't
>>>>>>>>>>> see any reason to use a different file just for auth config.
>>>>>>>>>>>
>>>>>>>>>>> There are many settings for the LDAP, and in the future we will
>>>>>>>>>> add other external sources also, so I thought it would be better if we have
>>>>>>>>>> different file for the authentication.
>>>>>>>>>>
>>>>>>>>>
>>>>>>>>> Sure, but our config file is small compared to many. Splitting
>>>>>>>>> things out is more confusing for users. If they want to do that themselves
>>>>>>>>> of course, they can add a config_local.py file which includes other files
>>>>>>>>> as needed.
>>>>>>>>>
>>>>>>>> Fixed.
>>>>>>>>
>>>>>>>>>
>>>>>>>>>
>>>>>>>>>> - I think all config options should be prefixed with LDAP_ as we
>>>>>>>>>>> may have things like CERT_FILE for other purposes too.
>>>>>>>>>>>
>>>>>>>>>>> Sure.
>>>>>>>>>>
>>>>>>>>> Done.
>>>>>>>>
>>>>>>>>> - I don't see any test cases.
>>>>>>>>>>>
>>>>>>>>>>> I will think about this, as right now no idea how to write test
>>>>>>>>>> cases for this.
>>>>>>>>>>
>>>>>>>>>
>>>>>>>>> It should be fairly straightforward to write tests for some of the
>>>>>>>>> functions in the auth classes. For testing the actual LDAP stuff, we
>>>>>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>>>>>> LDAP_STARTTLS, AD etc).
>>>>>>>>>
>>>>>>>>>
>>>>>>>> Done.
>>>>>>>>
>>>>>>>> Thanks,
>>>>>>>> Khushboo
>>>>>>>>
>>>>>>>>> Thanks.
>>>>>>>>>>>
>>>>>>>>>>> Thanks,
>>>>>>>>>> Khushboo
>>>>>>>>>>
>>>>>>>>>>>
>>>>>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>>>>>> [email protected]> wrote:
>>>>>>>>>>>
>>>>>>>>>>>> Hi,
>>>>>>>>>>>>
>>>>>>>>>>>> Please find the attached patch to support LDAP Authentication
>>>>>>>>>>>> in Server mode.
>>>>>>>>>>>> To test the patch, config_auth.py needs to be configured for
>>>>>>>>>>>> LDAP configurations. The config settings are explained in this file in
>>>>>>>>>>>> detail. After configuring the parameters, start the pgadmin server in
>>>>>>>>>>>> Server mode and connect with LDAP server with the valid user via login page.
>>>>>>>>>>>>
>>>>>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With the
>>>>>>>>>>>> TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>>>>>
>>>>>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>>>>>> anything.
>>>>>>>>>>>>
>>>>>>>>>>>> Note: For the document update I will create the task and assign
>>>>>>>>>>>> to Nidhi for the same.
>>>>>>>>>>>>
>>>>>>>>>>>> Thanks,
>>>>>>>>>>>> Khushboo
>>>>>>>>>>>>
>>>>>>>>>>>
>>>>>>>>>>>
>>>>>>>>>>> --
>>>>>>>>>>> Dave Page
>>>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>>>
>>>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>
>>>>>>>>> --
>>>>>>>>> Dave Page
>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>
>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>
>>>>>>>>
>>>
>>> --
>>> *Thanks & Regards*
>>> *Akshay Joshi*
>>>
>>> *Sr. Software Architect*
>>> *EnterpriseDB Software India Private Limited*
>>> *Mobile: +91 976-788-8246*
>>>
>>
>
> --
> *Thanks & Regards*
> *Akshay Joshi*
>
> *Sr. Software Architect*
> *EnterpriseDB Software India Private Limited*
> *Mobile: +91 976-788-8246*
>


Attachments:

  [application/octet-stream] RM_2186_v5.patch (70.6K, 3-RM_2186_v5.patch)
  download | inline diff:
diff --git a/requirements.txt b/requirements.txt
index f0cc71715..7f5f5f2cc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,3 +39,4 @@ python-dateutil>=2.8.0
 SQLAlchemy>=1.3.13
 Flask-Security-Too>=3.0.0
 sshtunnel>=0.1.4
+ldap3>=2.5.1
diff --git a/web/config.py b/web/config.py
index f508f8fc6..3343f4b6d 100644
--- a/web/config.py
+++ b/web/config.py
@@ -488,6 +488,65 @@ MASTER_PASSWORD_REQUIRED = True
 ##########################################################################
 ENHANCED_COOKIE_PROTECTION = True
 
+##########################################################################
+# External Authentication Sources
+##########################################################################
+
+# Default setting is internal
+# External Supported Sources: ldap
+# Multiple authentication can be achieved by setting this parameter to
+# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
+# in case of failure internal authentication will be done.
+
+AUTHENTICATION_SOURCES = ['internal']
+
+##########################################################################
+# LDAP Configuration
+##########################################################################
+
+# After ldap authentication, user will be added into the SQLite database
+# automatically, if set to True.
+# Set it to False, if user should not be added automatically,
+# in this case Admin has to add the user manually in the SQLite database.
+
+LDAP_AUTO_CREATE_USER = True
+
+# Connection timeout
+LDAP_CONNECTION_TIMEOUT = 10
+
+# Server connection details (REQUIRED)
+# example: ldap://<ip-address>:<port> or ldap://<hostname>:<port>
+LDAP_SERVER_URI = 'ldap://<ip-address>:<port>'
+
+# BaseDN (REQUIRED)
+# AD example:
+# (&(objectClass=user)(memberof=CN=MYGROUP,CN=Users,dc=example,dc=com))
+# OpenLDAP example: CN=Users,dc=example,dc=com
+LDAP_BASE_DN = '<Base-DN>'
+
+# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid'
+# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED)
+LDAP_USERNAME_ATTRIBUTE = '<User-id>'
+
+# Search ldap for further authentication
+LDAP_SEARCH_BASE_DN = '<Search-Base-DN>'
+
+# Filter string for the user search.
+# For OpenLDAP, '(cn=*)' may well be enough.
+# For AD, you might use '(objectClass=user)' (REQUIRED)
+LDAP_SEARCH_FILTER = '(objectclass=*)'
+
+# Search scope for users (one of BASE, LEVEL or SUBTREE)
+LDAP_SEARCH_SCOPE = 'SUBTREE'
+
+# Use TLS? If the URI scheme is ldaps://, this is ignored.
+LDAP_USE_STARTTLS = False
+
+# TLS/SSL certificates. Specify if required, otherwise leave empty
+LDAP_CA_CERT_FILE = ''
+LDAP_CERT_FILE = ''
+LDAP_KEY_FILE = ''
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py
new file mode 100644
index 000000000..89401686a
--- /dev/null
+++ b/web/migrations/versions/7fedf8531802_.py
@@ -0,0 +1,51 @@
+
+"""empty message
+
+Revision ID: 7fedf8531802
+Revises: aff1436e3c8c
+Create Date: 2020-02-26 11:24:54.353288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgadmin.model import db
+
+# revision identifiers, used by Alembic.
+revision = '7fedf8531802'
+down_revision = 'aff1436e3c8c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    db.engine.execute("ALTER TABLE user RENAME TO user_old")
+
+    db.engine.execute("""
+        CREATE TABLE user (
+            id INTEGER NOT NULL,
+            username VARCHAR(256) NOT NULL,
+            email VARCHAR(256),
+            password VARCHAR(256),
+            active BOOLEAN NOT NULL,
+            confirmed_at DATETIME,
+            masterpass_check VARCHAR(256),
+            auth_source VARCHAR(256) NOT NULL DEFAULT 'internal',
+            PRIMARY KEY (id),
+            UNIQUE (username, auth_source),
+            CHECK (active IN (0, 1))
+        );
+        """)
+
+    db.engine.execute("""
+        INSERT INTO user (
+            id, username, email, password, active, confirmed_at, masterpass_check
+        ) SELECT
+            id, email, email, password, active, confirmed_at, masterpass_check
+        FROM user_old""")
+
+    db.engine.execute("DROP TABLE user_old")
+
+
+def downgrade():
+    pass
diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py
index 81ef6c396..45d33b72d 100644
--- a/web/pgAdmin4.py
+++ b/web/pgAdmin4.py
@@ -160,6 +160,18 @@ if 'PGADMIN_INT_KEY' in globals():
 else:
     app.PGADMIN_INT_KEY = ''
 
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_SUPPORTED_AUTH_SOURCE = ['internal', 'ldap']
+
+if len(config.AUTHENTICATION_SOURCES) > 0:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = config.AUTHENTICATION_SOURCES[0]
+else:
+    app.PGADMIN_EXTERNAL_AUTH_SOURCE = app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+app.logger.debug(
+    "Authentication Source: %s" % app.PGADMIN_DEFAULT_AUTH_SOURCE)
+
 # Output a startup message if we're not under the runtime and startup.
 # If we're under WSGI, we don't need to worry about this
 if __name__ == '__main__':
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index 820c8015a..5ca2ae67a 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -38,7 +38,7 @@ from datetime import timedelta
 from pgadmin.setup import get_version, set_version
 from pgadmin.utils.ajax import internal_server_error
 from pgadmin.utils.csrf import pgCSRFProtect
-
+from pgadmin import authenticate
 
 # If script is running under python3, it will not have the xrange function
 # defined
@@ -398,6 +398,7 @@ def create_app(app_name=None):
     # Load all available server drivers
     ##########################################################################
     driver.init_app(app)
+    authenticate.init_app(app)
 
     ##########################################################################
     # Register language to the preferences after login
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
new file mode 100644
index 000000000..63f524e23
--- /dev/null
+++ b/web/pgadmin/authenticate/__init__.py
@@ -0,0 +1,156 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the Authentication."""
+
+import flask
+import pickle
+from flask import current_app, flash
+from flask_babelex import gettext
+from flask_security import current_user
+from flask_security.views import _security, _ctx
+from flask_security.utils import config_value, get_post_logout_redirect
+from flask import session
+
+import config
+from pgadmin.utils import PgAdminModule
+from .registry import AuthSourceRegistry
+
+MODULE_NAME = 'authenticate'
+
+
+class AuthenticateModule(PgAdminModule):
+    def get_exposed_url_endpoints(self):
+        return ['authenticate.login']
+
+
+blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
+
+
[email protected]('/login', endpoint='login', methods=['GET', 'POST'])
+def login():
+    """
+    Entry point for all the authentication sources.
+    The user input will be validated and authenticated.
+    """
+    form = _security.login_form()
+    auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES)
+    session['_auth_source_manager_obj'] = None
+
+    # Validate the user
+    if not auth_obj.validate():
+        for field in form.errors:
+            for error in form.errors[field]:
+                flash(error, 'warning')
+            return flask.redirect(get_post_logout_redirect())
+
+    # Authenticate the user
+    status, msg = auth_obj.authenticate()
+    if status:
+        # Login the user
+        status, msg = auth_obj.login()
+        if not status:
+            flash(gettext(msg), 'danger')
+            return flask.redirect(get_post_logout_redirect())
+
+        session['_auth_source_manager_obj'] = auth_obj.as_dict()
+        return flask.redirect('/')
+
+    flash(gettext(msg), 'danger')
+    return flask.redirect(get_post_logout_redirect())
+
+
+class AuthSourceManager():
+    """This class will manage all the authentication sources.
+     """
+    def __init__(self, form, sources):
+        self.form = form
+        self.auth_sources = sources
+        self.source = None
+        self.source_friendly_name = None
+
+    def as_dict(self):
+        """
+        Returns the dictionary object representing this object.
+        """
+
+        res = dict()
+        res['source_friendly_name'] = self.source_friendly_name
+        res['auth_sources'] = self.auth_sources
+
+        return res
+
+    def set_source(self, source):
+        self.source = source
+
+    @property
+    def get_source(self):
+        return self.source
+
+    def set_source_friendly_name(self, name):
+        self.source_friendly_name = name
+
+    @property
+    def get_source_friendly_name(self):
+        return self.source_friendly_name
+
+    def validate(self):
+        """Validate through all the sources."""
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            if source.validate(self.form):
+                return True
+        return False
+
+    def authenticate(self):
+        """Authenticate through all the sources."""
+        status = False
+        msg = None
+        for src in self.auth_sources:
+            source = get_auth_sources(src)
+            status, msg = source.authenticate(self.form)
+            if status:
+                self.set_source(source)
+                return status, msg
+        return status, msg
+
+    def login(self):
+        status, msg = self.source.login(self.form)
+        if status:
+            self.set_source_friendly_name(self.source.get_friendly_name())
+        return status, msg
+
+
+def get_auth_sources(type):
+    """Get the authenticated source object from the registry"""
+
+    auth_sources = getattr(current_app, '_pgadmin_auth_sources', None)
+
+    if auth_sources is None or not isinstance(auth_sources, dict):
+        auth_sources = dict()
+
+    if type in auth_sources:
+        return auth_sources[type]
+
+    auth_source = AuthSourceRegistry.create(type)
+
+    if auth_source is not None:
+        auth_sources[type] = auth_source
+        setattr(current_app, '_pgadmin_auth_sources', auth_sources)
+
+    return auth_source
+
+
+def init_app(app):
+    auth_sources = dict()
+
+    setattr(app, '_pgadmin_auth_sources', auth_sources)
+    AuthSourceRegistry.load_auth_sources()
+
+    return auth_sources
diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py
new file mode 100644
index 000000000..62032f4e2
--- /dev/null
+++ b/web/pgadmin/authenticate/internal.py
@@ -0,0 +1,98 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements Internal Authentication"""
+
+import six
+from flask import current_app
+from flask_security import login_user
+from abc import abstractmethod, abstractproperty
+from flask_babelex import gettext
+
+from .registry import AuthSourceRegistry
+from pgadmin.model import User
+
+
[email protected]_metaclass(AuthSourceRegistry)
+class BaseAuthentication(object):
+
+    DEFAULT_MSG = {
+        'USER_DOES_NOT_EXIST': 'Specified user does not exist',
+        'LOGIN_FAILED': 'Login failed',
+        'EMAIL_NOT_PROVIDED': 'Email/Username not provided',
+        'PASSWORD_NOT_PROVIDED': 'Password not provided'
+    }
+
+    @abstractproperty
+    def get_friendly_name(cls):
+        pass
+
+    @abstractmethod
+    def authenticate(cls):
+        pass
+
+    def validate(self, form):
+        username = form.data['email']
+        password = form.data['password']
+
+        if username is None or username == '':
+            form.email.errors = list(form.email.errors)
+            form.email.errors.append(gettext(
+                self.messages('EMAIL_NOT_PROVIDED')))
+            return False
+        if password is None or password == '':
+            form.password.errors = list(form.password.errors)
+            form.password.errors.append(
+                self.messages('PASSWORD_NOT_PROVIDED'))
+            return False
+
+        return True
+
+    def login(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user', None)
+
+        if user is None:
+            user = User.query.filter_by(username=username).first()
+
+        if user is None:
+            current_app.logger.exception(
+                self.messages('USER_DOES_NOT_EXIST'))
+            return False, self.messages('USER_DOES_NOT_EXIST')
+
+        # Login user through flask_security
+        status = login_user(user)
+        if not status:
+            current_app.logger.exception(self.messages('LOGIN_FAILED'))
+            return False, self.messages('LOGIN_FAILED')
+        return True, None
+
+    def messages(self, msg_key):
+        return self.DEFAULT_MSG[msg_key] if msg_key in self.DEFAULT_MSG\
+            else None
+
+
+class InternalAuthentication(BaseAuthentication):
+
+    def get_friendly_name(cls):
+        return gettext("internal")
+
+    def validate(self, form):
+        """User validation"""
+
+        # Flask security validation
+        return form.validate_on_submit()
+
+    def authenticate(self, form):
+        username = form.data['email']
+        user = getattr(form, 'user',
+                       User.query.filter_by(username=username).first())
+        if user and user.is_authenticated and form.validate_on_submit():
+            return True, None
+        return False, self.messages('USER_DOES_NOT_EXIST')
diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py
new file mode 100644
index 000000000..d3df34204
--- /dev/null
+++ b/web/pgadmin/authenticate/ldap.py
@@ -0,0 +1,186 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""A blueprint module implementing the ldap authentication."""
+
+import ssl
+import config
+from ldap3 import Connection, Server, Tls, ALL, ALL_ATTRIBUTES
+from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError,\
+    LDAPInvalidScopeError, LDAPAttributeError, LDAPInvalidFilterError,\
+    LDAPStartTLSError
+from flask_babelex import gettext
+
+from .internal import BaseAuthentication
+from pgadmin.model import User, ServerGroup, db, Role
+from flask_security import login_user
+from flask import current_app
+from pgadmin.tools.user_management import create_user
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class LDAPAuthentication(BaseAuthentication):
+    """Ldap Authentication Class"""
+
+    def get_friendly_name(self):
+        return gettext("ldap")
+
+    def authenticate(self, form):
+        self.username = form.data['email']
+        self.password = form.data['password']
+
+        status, msg = self.connect()
+
+        if not status:
+            return status, msg
+
+        status, user_email = self.search_ldap_user()
+
+        if not status:
+            return status, user_email
+
+        return self.__auto_create_user(user_email)
+
+    def connect(self):
+        """Setup the connection to the LDAP server and authenticate the user.
+        """
+
+        # Parse the server URI
+        uri = getattr(config, 'LDAP_SERVER_URI', None)
+
+        if uri:
+            uri = urlparse(uri)
+
+        # Create the TLS configuration object if required
+        tls = None
+
+        if type(uri) == str:
+            return False, "LDAP configuration error: Set the proper LDAP URI."
+
+        if uri.scheme == 'ldaps' or config.LDAP_USE_STARTTLS:
+
+            ca_cert_file = getattr(config, 'LDAP_CA_CERT_FILE', None)
+            cert_file = getattr(config, 'LDAP_CERT_FILE', None)
+            key_file = getattr(config, 'LDAP_KEY_FILE', None)
+            cert_validate = ssl.CERT_NONE
+
+            if ca_cert_file and cert_file and key_file:
+                cert_validate = ssl.CERT_REQUIRED
+
+            tls = Tls(
+                local_private_key_file=key_file,
+                local_certificate_file=cert_file,
+                validate=cert_validate,
+                version=ssl.PROTOCOL_TLSv1,
+                ca_certs_file=ca_cert_file)
+
+        try:
+            # Create the server object
+            server = Server(uri.hostname,
+                            port=uri.port,
+                            use_ssl=(uri.scheme == 'ldaps'),
+                            get_info=ALL,
+                            tls=tls,
+                            connect_timeout=config.LDAP_CONNECTION_TIMEOUT)
+        except ValueError as e:
+            return False, "LDAP configuration error: %s." % e
+
+        # Create the connection
+        try:
+            user_dn = "{0}={1},{2}".format(config.LDAP_USERNAME_ATTRIBUTE,
+                                           self.username,
+                                           config.LDAP_BASE_DN
+                                           )
+            self.conn = Connection(server,
+                                   user=user_dn,
+                                   password=self.password,
+                                   auto_bind=True
+                                   )
+
+        except LDAPSocketOpenError as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+        except LDAPBindError as e:
+            current_app.logger.exception(
+                "Error binding to the LDAP server.")
+            return False, "Error binding to the LDAP server."
+        except Exception as e:
+            current_app.logger.exception(
+                "Error connecting to the LDAP server: %s\n" % e)
+            return False, "Error connecting to the LDAP server:" \
+                          " %s\n" % e.args[0]
+
+        # Enable TLS if STARTTLS is configured
+        if not uri.scheme == 'ldaps' and config.LDAP_USE_STARTTLS:
+            try:
+                self.conn.start_tls()
+            except LDAPStartTLSError as e:
+                current_app.logger.exception(
+                    "Error starting TLS: %s\n" % e)
+                return False, "Error starting TLS: %s\n" % e.args[0]
+
+        return True, None
+
+    def __auto_create_user(self, user_email):
+        """Add the ldap user to the internal SQLite database."""
+        if config.LDAP_AUTO_CREATE_USER:
+            user = User.query.filter_by(
+                username=self.username).first()
+            if user is None:
+                return create_user({
+                    'username': self.username,
+                    'email': user_email,
+                    'role': 2,
+                    'active': True,
+                    'auth_source': 'ldap'
+                })
+
+        return True, None
+
+    def search_ldap_user(self):
+        """Get a list of users from the LDAP server based on config
+         search criteria."""
+        try:
+            self.conn.search(search_base=config.LDAP_SEARCH_BASE_DN,
+                             search_filter=config.LDAP_SEARCH_FILTER,
+                             search_scope=config.LDAP_SEARCH_SCOPE,
+                             attributes=ALL_ATTRIBUTES
+                             )
+
+        except LDAPInvalidScopeError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPAttributeError as e:
+            current_app.logger.exception("Error searching the LDAP directory:"
+                                         " %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+        except LDAPInvalidFilterError as e:
+            current_app.logger.exception(
+                "Error searching the LDAP directory: %s\n" % e)
+            return False, "Error searching the LDAP directory:" \
+                          " %s\n" % e.args[0]
+
+        users = []
+        for entry in self.conn.entries:
+            user_email = None
+            if config.LDAP_USERNAME_ATTRIBUTE in entry and self.username == \
+                    entry[config.LDAP_USERNAME_ATTRIBUTE].value:
+                if 'mail' in entry:
+                    user_email = entry['mail'].value
+                return True, user_email
+        return False, None
diff --git a/web/pgadmin/authenticate/registry.py b/web/pgadmin/authenticate/registry.py
new file mode 100644
index 000000000..905f55643
--- /dev/null
+++ b/web/pgadmin/authenticate/registry.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""External Authentication Registry."""
+
+
+from flask_babelex import gettext
+from abc import ABCMeta
+
+
+def _decorate_cls_name(module_name):
+    length = len(__package__) + 1
+
+    if len(module_name) > length and module_name.startswith(__package__):
+        return module_name[length:]
+
+    return module_name
+
+
+class AuthSourceRegistry(ABCMeta):
+    registry = None
+    auth_sources = dict()
+
+    def __init__(cls, name, bases, d):
+
+        # Register this type of auth_sources, based on the module name
+        # Avoid registering the BaseAuthentication itself
+
+        AuthSourceRegistry.registry[_decorate_cls_name(d['__module__'])] = cls
+        ABCMeta.__init__(cls, name, bases, d)
+
+    @classmethod
+    def create(cls, name, **kwargs):
+
+        if name in AuthSourceRegistry.auth_sources:
+            return AuthSourceRegistry.auth_sources[name]
+
+        if name in AuthSourceRegistry.registry:
+            AuthSourceRegistry.auth_sources[name] = \
+                (AuthSourceRegistry.registry[name])(**kwargs)
+            return AuthSourceRegistry.auth_sources[name]
+
+        raise NotImplementedError(
+            gettext(
+                "Authentication source '{0}' has not been implemented."
+            ).format(name)
+        )
+
+    @classmethod
+    def load_auth_sources(cls):
+        # Initialize the registry only if it has not yet been initialized
+        if AuthSourceRegistry.registry is None:
+            AuthSourceRegistry.registry = dict()
+
+        from importlib import import_module
+        from werkzeug.utils import find_modules
+
+        for module_name in find_modules(__package__, True):
+            module = import_module(module_name)
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 30af3e11b..862490820 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -45,6 +45,7 @@ from pgadmin.browser.register_browser_preferences import \
 from pgadmin.utils.master_password import validate_master_password, \
     set_masterpass_check_text, cleanup_master_password, get_crypt_key, \
     set_crypt_key, process_masterpass_disabled
+from pgadmin.model import User
 
 try:
     import urllib.request as urlreq
@@ -580,12 +581,24 @@ def index():
 
                 flash(msg, 'warning')
 
+    auth_only_internal = False
+    auth_source = []
+
+    if config.SERVER_MODE:
+        if len(config.AUTHENTICATION_SOURCES) == 1\
+                and 'internal' in config.AUTHENTICATION_SOURCES:
+            auth_only_internal = True
+        auth_source = session['_auth_source_manager_obj'][
+            'source_friendly_name']
+
     response = Response(render_template(
         MODULE_NAME + "/index.html",
-        username=current_user.email,
+        username=current_user.username,
+        auth_source=auth_source,
         is_admin=current_user.has_role("Administrator"),
         logout_url=_get_logout_url(),
-        _=gettext
+        _=gettext,
+        auth_only_internal=auth_only_internal
     ))
 
     # Set the language cookie after login, so next time the user will have that
@@ -994,43 +1007,60 @@ if hasattr(config, 'SECURITY_RECOVERABLE') and config.SECURITY_RECOVERABLE:
             form = form_class()
 
         if form.validate_on_submit():
-            try:
-                send_reset_password_instructions(form.user)
-            except SOCKETErrorException as e:
-                # Handle socket errors which are not covered by SMTPExceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP Socket error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except (SMTPConnectError, SMTPResponseException,
-                    SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
-                    SMTPException, SMTPAuthenticationError, SMTPSenderRefused,
-                    SMTPRecipientsRefused) as e:
-
-                # Handle smtp specific exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'SMTP error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
-                      'danger')
-                has_error = True
-            except Exception as e:
-                # Handle other exceptions.
-                logging.exception(str(e), exc_info=True)
-                flash(gettext(u'Error: {}\n'
-                              u'Your password has not been changed.'
-                              ).format(e),
+            # Check the Authentication source of the User
+            user = User.query.filter_by(
+                email=form.data['email'],
+                auth_source=current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+            ).first()
+
+            if user is None:
+                # If the user is not an internal user, raise the exception
+                flash(gettext('Your account is authenticated using an '
+                              'external {} source. '
+                              'Please contact the administrators of this '
+                              'service if you need to reset your password.'
+                              ).format(form.user.auth_source),
                       'danger')
                 has_error = True
+            if not has_error:
+                try:
+                    send_reset_password_instructions(form.user)
+                except SOCKETErrorException as e:
+                    # Handle socket errors which are not
+                    # covered by SMTPExceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP Socket error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except (SMTPConnectError, SMTPResponseException,
+                        SMTPServerDisconnected, SMTPDataError, SMTPHeloError,
+                        SMTPException, SMTPAuthenticationError,
+                        SMTPSenderRefused, SMTPRecipientsRefused) as e:
+
+                    # Handle smtp specific exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'SMTP error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
+                except Exception as e:
+                    # Handle other exceptions.
+                    logging.exception(str(e), exc_info=True)
+                    flash(gettext(u'Error: {}\n'
+                                  u'Your password has not been changed.'
+                                  ).format(e),
+                          'danger')
+                    has_error = True
 
             if request.json is None and not has_error:
                 do_flash(*get_message('PASSWORD_RESET_REQUEST',
                                       email=form.user.email))
 
         if request.json and not has_error:
-            return _render_json(form, include_user=False)
+            return default_render_json(form, include_user=False)
 
         return _security.render_template(
             config_value('FORGOT_PASSWORD_TEMPLATE'),
diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html
index 682c23d65..b389b9574 100644
--- a/web/pgadmin/browser/templates/browser/index.html
+++ b/web/pgadmin/browser/templates/browser/index.html
@@ -142,6 +142,7 @@ window.onload = function(e){
                 <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown"
                    role="button" aria-expanded="false" id="navbar-user"></a>
                 <ul class="dropdown-menu dropdown-menu-right" role="menu">
+                    {% if auth_only_internal %}
                     <li>
                         <a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.change_password(
                           '{{ url_for('browser.change_password') }}'
@@ -150,6 +151,7 @@ window.onload = function(e){
                         </a>
                     </li>
                     <li class="dropdown-divider"></li>
+                    {% endif %}
                     {% if is_admin %}
                     <li><a class="dropdown-item" href="#" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
                     <li class="dropdown-divider"></li>
diff --git a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
index 72ec97e59..eded8b68a 100644
--- a/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
+++ b/web/pgadmin/browser/templates/browser/macros/gravatar_icon.macro
@@ -4,5 +4,5 @@ we will not associate our application with Gravatar module which will make
 'gravatar' filter unavailable in Jinja templates
 ###########################################################################}
 {% macro PREPARE_HTML() -%}
-'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} <span class="caret"></span>';
+'<img src = "{{ username | gravatar }}" width = "18" height = "18" alt = "Gravatar image for {{ username }}" > {{ username }} ({{auth_source}}) <span class="caret"></span>';
 {%- endmacro %}
diff --git a/web/pgadmin/browser/tests/test_change_password.py b/web/pgadmin/browser/tests/test_change_password.py
index 04c49a23e..fb86e4dfd 100644
--- a/web/pgadmin/browser/tests/test_change_password.py
+++ b/web/pgadmin/browser/tests/test_change_password.py
@@ -95,6 +95,7 @@ class ChangePasswordTestCase(BaseTestGenerator):
             response = self.tester.post(
                 '/user_management/user/',
                 data=json.dumps(dict(
+                    username=self.username,
                     email=self.username,
                     newPassword=self.password,
                     confirmPassword=self.password,
diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py
new file mode 100644
index 000000000..4a76569d1
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_login.py
@@ -0,0 +1,89 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+
+
+class LDAPLoginTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality
+    by validating different scenarios.
+    """
+
+    scenarios = [
+        ('LDAP Authentication', dict(
+            config_key_param='ldap',
+            is_gravtar_image_check=False)),
+        ('LDAP With SSL Authentication', dict(
+            config_key_param='ldap_with_ssl',
+            is_gravtar_image_check=False)),
+        ('LDAP With TLS Authentication', dict(
+            config_key_param='ldap_with_tls',
+            is_gravtar_image_check=False)),
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client
+        as we are testing ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        if 'ldap_config' in config_data and \
+                type(config_data['ldap_config']) is list and\
+                len(config_data['ldap_config']) > 0 and\
+                self.config_key_param in config_data['ldap_config'][0]:
+            ldap_config = config_data['ldap_config'][0][self.config_key_param]
+
+            app_config.AUTHENTICATION_SOURCES = ['ldap']
+            app_config.LDAP_AUTO_CREATE_USER = True
+            app_config.LDAP_SERVER_URI = ldap_config['uri']
+            app_config.LDAP_BASE_DN = ldap_config['base_dn']
+            app_config.LDAP_USERNAME_ATTRIBUTE = ldap_config[
+                'username_atr']
+            app_config.LDAP_SEARCH_BASE_DN = ldap_config[
+                'search_base_dn']
+            app_config.LDAP_SEARCH_FILTER = ldap_config['search_filter']
+            app_config.LDAP_USE_STARTTLS = ldap_config['use_starttls']
+            app_config.LDAP_CA_CERT_FILE = ldap_config['ca_cert_file']
+            app_config.LDAP_CERT_FILE = ldap_config['cert_file']
+            app_config.LDAP_KEY_FILE = ldap_config['key_file']
+        else:
+            self.skipTest(
+                "LDAP config not set."
+            )
+
+    def runTest(self):
+        """This function checks login functionality."""
+        username = config_data['pgAdmin4_ldap_credentials']['login_username']
+        password = config_data['pgAdmin4_ldap_credentials']['login_password']
+
+        res = self.tester.login(username, password, True)
+
+        respdata = 'Gravatar image for %s' %\
+                   config_data['pgAdmin4_ldap_credentials']['login_username']
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
new file mode 100644
index 000000000..90385242c
--- /dev/null
+++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py
@@ -0,0 +1,84 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2020, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import config as app_config
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from regression.test_setup import config_data
+from pgadmin.authenticate.registry import AuthSourceRegistry
+
+if sys.version_info < (3, 3):
+    from mock import patch
+else:
+    from unittest.mock import patch
+
+
+class LDAPLoginMockTestCase(BaseTestGenerator):
+    """
+    This class checks ldap login functionality by mocking
+    ldap connection and ldap search functionality.
+    """
+
+    scenarios = [
+        ('LDAP Authentication with Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=True,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP Authentication without Auto Create User', dict(
+            auth_source=['ldap'],
+            auto_create_user=False,
+            username='ldap_user',
+            password='ldap_pass')),
+        ('LDAP + Internal Authentication', dict(
+            auth_source=['ldap', 'internal'],
+            auto_create_user=False,
+            username=config_data[
+                'pgAdmin4_login_credentials']['login_username'],
+            password=config_data[
+                'pgAdmin4_login_credentials']['login_password']
+        ))
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        We need to logout the test client as we are testing
+        ldap login scenarios.
+        """
+        cls.tester.logout()
+
+    def setUp(self):
+        app_config.AUTHENTICATION_SOURCES = self.auth_source
+        app_config.LDAP_AUTO_CREATE_USER = self.auto_create_user
+
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'connect',
+                  return_value=[True, "Done"])
+    @patch.object(AuthSourceRegistry.registry['ldap'], 'search_ldap_user',
+                  return_value=[True, ''])
+    def runTest(self, conn_mock_obj, search_mock_obj):
+        """This function checks ldap login functionality."""
+
+        res = self.tester.login(self.username, self.password, True)
+        respdata = 'Gravatar image for %s' % self.username
+        self.assertTrue(respdata in res.data.decode('utf8'))
+
+    def tearDown(self):
+        self.tester.logout()
+
+    @classmethod
+    def tearDownClass(cls):
+        """
+        We need to again login the test client as soon as test scenarios
+        finishes.
+        """
+        cls.tester.logout()
+        app_config.AUTHENTICATION_SOURCES = ['internal']
+        utils.login_tester_account(cls.tester)
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index f588e401c..e3af660b0 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 #
 ##########################################################################
 
-SCHEMA_VERSION = 24
+SCHEMA_VERSION = 25
 
 ##########################################################################
 #
@@ -66,13 +66,15 @@ class User(db.Model, UserMixin):
     """Define a user object"""
     __tablename__ = 'user'
     id = db.Column(db.Integer, primary_key=True)
-    email = db.Column(db.String(256), unique=True, nullable=False)
+    email = db.Column(db.String(256), nullable=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
     password = db.Column(db.String(256))
     active = db.Column(db.Boolean(), nullable=False)
     confirmed_at = db.Column(db.DateTime())
     masterpass_check = db.Column(db.String(256))
     roles = db.relationship('Role', secondary=roles_users,
                             backref=db.backref('users', lazy='dynamic'))
+    auth_source = db.Column(db.String(16), unique=True, nullable=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/templates/security/fields.html b/web/pgadmin/templates/security/fields.html
index efb126b2e..c505da366 100644
--- a/web/pgadmin/templates/security/fields.html
+++ b/web/pgadmin/templates/security/fields.html
@@ -9,3 +9,14 @@
     {% endif %}
 </div>
 {% endmacro %}
+{% macro render_username_with_errors(field, type) %}
+<div class="form-group mb-3 {% if field.errors %} has-error{% endif %}">
+    <input class="form-control" placeholder="{{ field.label.text }} / Username" name="{{ field.name }}"
+           type="{% if type %}{{ type }}{% else %}{{ field.type }}{% endif %}" autofocus>
+    {% if field.errors %}
+    {% for error in field.errors %}
+    <span class="form-text">{{ error }}</span>
+    {% endfor %}
+    {% endif %}
+</div>
+{% endmacro %}
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 7515c2c2a..2e92d7b12 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -7,10 +7,10 @@
 {% block panel_title %}{{ _('Login') }}{% endblock %}
 {% block panel_body %}
 {% if config.SERVER_MODE %}
-<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
+<form action="{{ url_for('authenticate.login') }}" method="POST" name="login_user_form">
     {{ login_user_form.hidden_tag() }}
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
-    {{ render_field_with_errors(login_user_form.email, "text") }}
+    {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
     <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html
index 7de1d9d90..1452de8ca 100644
--- a/web/pgadmin/templates/security/panel.html
+++ b/web/pgadmin/templates/security/panel.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% from "security/fields.html" import render_field_with_errors %}
+{% from "security/fields.html" import render_field_with_errors, render_username_with_errors %}
 {% block body %}
 <div class="container-fluid h-100 login_page">
     {% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 55365173f..78aa99a15 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -74,7 +74,8 @@ class UserManagementModule(PgAdminModule):
             'user_management.roles', 'user_management.role',
             'user_management.update_user', 'user_management.delete_user',
             'user_management.create_user', 'user_management.users',
-            'user_management.user', current_app.login_manager.login_view
+            'user_management.user', current_app.login_manager.login_view,
+            'user_management.auth_sources', 'user_management.auth_sources'
         ]
 
 
@@ -100,7 +101,7 @@ def validate_user(data):
         else:
             raise Exception(_("Passwords do not match."))
 
-    if 'email' in data and data['email'] != "":
+    if 'email' in data and data['email'] and data['email'] != "":
         if email_filter.match(data['email']):
             new_data['email'] = data['email']
         else:
@@ -112,6 +113,12 @@ def validate_user(data):
     if 'active' in data and data['active'] != "":
         new_data['active'] = data['active']
 
+    if 'username' in data and data['username'] != "":
+        new_data['username'] = data['username']
+
+    if 'auth_source' in data and data['auth_source'] != "":
+        new_data['auth_source'] = data['auth_source']
+
     return new_data
 
 
@@ -140,6 +147,7 @@ def script():
 @pgCSRFProtect.exempt
 @login_required
 def current_user_info():
+
     return Response(
         response=render_template(
             "user_management/js/current_user.js",
@@ -148,13 +156,15 @@ def current_user_info():
             user_id=current_user.id,
             email=current_user.email,
             name=(
-                current_user.email.split('@')[0] if config.SERVER_MODE is True
+                current_user.username.split('@')[0] if
+                config.SERVER_MODE is True
                 else 'postgres'
             ),
             allow_save_password='true' if config.ALLOW_SAVE_PASSWORD
             else 'false',
             allow_save_tunnel_password='true'
             if config.ALLOW_SAVE_TUNNEL_PASSWORD else 'false',
+            auth_sources=config.AUTHENTICATION_SOURCES,
         ),
         status=200,
         mimetype="application/javascript"
@@ -180,9 +190,11 @@ def user(uid):
         u = User.query.get(uid)
 
         res = {'id': u.id,
+               'username': u.username,
                'email': u.email,
                'active': u.active,
-               'role': u.roles[0].id
+               'role': u.roles[0].id,
+               'auth_source': u.auth_source
                }
     else:
         users = User.query.all()
@@ -190,9 +202,11 @@ def user(uid):
         users_data = []
         for u in users:
             users_data.append({'id': u.id,
+                               'username': u.username,
                                'email': u.email,
                                'active': u.active,
-                               'role': u.roles[0].id
+                               'role': u.roles[0].id,
+                               'auth_source': u.auth_source
                                })
 
         res = users_data
@@ -215,11 +229,29 @@ def create():
         request.data, encoding='utf-8'
     )
 
-    for f in ('email', 'role', 'active', 'newPassword', 'confirmPassword'):
+    status, res = create_user(data)
+
+    if not status:
+        return internal_server_error(errormsg=res)
+
+    return ajax_response(
+        response=res,
+        status=200
+    )
+
+
+def create_user(data):
+    if 'auth_source' in data and data['auth_source'] != 'internal':
+        req_params = ('username', 'role', 'active', 'auth_source')
+    else:
+        req_params = ('email', 'role', 'active', 'newPassword',
+                      'confirmPassword')
+
+    for f in req_params:
         if f in data and data[f] != '':
             continue
         else:
-            return bad_request(errormsg=_("Missing field: '{0}'".format(f)))
+            return False, _("Missing field: '{0}'".format(f))
 
     try:
         new_data = validate_user(data)
@@ -228,13 +260,23 @@ def create():
             new_data['roles'] = [Role.query.get(new_data['roles'])]
 
     except Exception as e:
-        return bad_request(errormsg=_(str(e)))
+        return False, str(e)
 
     try:
-        usr = User(email=new_data['email'],
+
+        username = new_data['username'] if 'username' in new_data \
+            else new_data['email']
+        email = new_data['email'] if 'email' in new_data else None
+        password = new_data['password'] if 'password' in new_data else None
+        auth_source = new_data['auth_source'] if 'auth_source' in new_data \
+            else current_app.PGADMIN_DEFAULT_AUTH_SOURCE
+
+        usr = User(username=username,
+                   email=email,
                    roles=new_data['roles'],
                    active=new_data['active'],
-                   password=new_data['password'])
+                   password=password,
+                   auth_source=auth_source)
         db.session.add(usr)
         db.session.commit()
         # Add default server group for new user.
@@ -242,18 +284,15 @@ def create():
         db.session.add(server_group)
         db.session.commit()
     except Exception as e:
-        return internal_server_error(errormsg=str(e))
+        return False, str(e)
 
-    res = {'id': usr.id,
-           'email': usr.email,
-           'active': usr.active,
-           'role': usr.roles[0].id
-           }
-
-    return ajax_response(
-        response=res,
-        status=200
-    )
+    return True, {
+        'id': usr.id,
+        'username': usr.username,
+        'email': usr.email,
+        'active': usr.active,
+        'role': usr.roles[0].id
+    }
 
 
 @blueprint.route(
@@ -337,9 +376,11 @@ def update(uid):
         db.session.commit()
 
         res = {'id': usr.id,
+               'username': usr.username,
                'email': usr.email,
                'active': usr.active,
-               'role': usr.roles[0].id
+               'role': usr.roles[0].id,
+               'auth_source': usr.auth_source
                }
 
         return ajax_response(
@@ -384,3 +425,17 @@ def role(rid):
         response=res,
         status=200
     )
+
+
[email protected](
+    '/auth_sources/', methods=['GET'], endpoint='auth_sources'
+)
+def auth_sources():
+    sources = []
+    for source in current_app.PGADMIN_SUPPORTED_AUTH_SOURCE:
+        sources.append({'label': source, 'value': source})
+
+    return ajax_response(
+        response=sources,
+        status=200
+    )
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 2b1ed1727..368452fab 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -9,12 +9,12 @@
 
 define([
   'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
-  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node',
+  'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
   'pgadmin.user_management.current_user',
   'backgrid.select.all', 'backgrid.filter',
 ], function(
   gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
-  pgNode, userInfo
+  pgNode, pgBackform, userInfo
 ) {
 
   // if module is already initialized, refer to that.
@@ -24,6 +24,8 @@ define([
 
   var USERURL = url_for('user_management.users'),
     ROLEURL = url_for('user_management.roles'),
+    SOURCEURL = url_for('user_management.auth_sources'),
+    AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length  == 1 && userInfo['auth_sources'].includes('internal')) ? true : false,
     userFilter = function(collection) {
       return (new Backgrid.Extension.ClientSideFilter({
         collection: collection,
@@ -33,6 +35,41 @@ define([
       }));
     };
 
+  // Integer Cell for Columns Length and Precision
+  var PasswordDepCell = Backgrid.Extension.PasswordDepCell =
+    Backgrid.Extension.PasswordCell.extend({
+      initialize: function() {
+        Backgrid.Extension.PasswordCell.prototype.initialize.apply(this, arguments);
+        Backgrid.Extension.DependentCell.prototype.initialize.apply(this, arguments);
+      },
+      dependentChanged: function () {
+        this.$el.empty();
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+
+        this.delegateEvents();
+        return this;
+      },
+      render: function() {
+        Backgrid.NumberCell.prototype.render.apply(this, arguments);
+
+        var model = this.model,
+          column = this.column,
+          editable = this.column.get('editable'),
+          is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
+
+        if (is_editable){ this.$el.addClass('editable'); }
+        else { this.$el.removeClass('editable'); }
+        return this;
+      },
+      remove: Backgrid.Extension.DependentCell.prototype.remove,
+    });
+
   pgBrowser.UserManagement = {
     init: function() {
       if (this.initialized)
@@ -235,20 +272,67 @@ define([
     // Callback to draw User Management Dialog.
     show_users: function() {
       if (!userInfo['is_admin']) return;
-      var Roles = [];
+      var Roles = [],
+        Sources = [];
 
       var UserModel = pgBrowser.Node.Model.extend({
           idAttribute: 'id',
           urlRoot: USERURL,
           defaults: {
             id: undefined,
+            username: undefined,
             email: undefined,
             active: true,
             role: undefined,
             newPassword: undefined,
             confirmPassword: undefined,
+            auth_source: 'internal',
+            authOnlyInternal: AUTH_ONLY_INTERNAL,
           },
           schema: [{
+            id: 'auth_source',
+            label: gettext('Authentication Source'),
+            type: 'text',
+            control: 'Select2',
+            url: url_for('user_management.auth_sources'),
+            cellHeaderClasses: 'width_percent_30',
+            visible: function(m) {
+              if (m.get('authOnlyInternal')) return false;
+              return true;
+            },
+            disabled: false,
+            cell: 'Select2',
+            select2: {
+              allowClear: false,
+              openOnEnter: false,
+              first_empty: false,
+            },
+            options: function() {
+              return Sources;
+            },
+            editable: function(m) {
+              if (m instanceof Backbone.Collection) {
+                return true;
+              }
+              if (m.isNew() && !m.get('authOnlyInternal')) {
+                return true;
+              } else {
+                return false;
+              }
+            },
+          }, {
+            id: 'username',
+            label: gettext('Username'),
+            type: 'text',
+            cell: Backgrid.Extension.StringDepCell,
+            cellHeaderClasses: 'width_percent_30',
+            deps: ['auth_source'],
+            editable: function(m) {
+              if (m.get('authOnlyInternal') || m.get('auth_source') == 'internal') return false;
+              return true;
+            },
+            disabled: false,
+          }, {
             id: 'email',
             label: gettext('Email'),
             type: 'text',
@@ -256,6 +340,8 @@ define([
             cellHeaderClasses: 'width_percent_30',
             deps: ['id'],
             editable: function(m) {
+              if (!m.get('authOnlyInternal')) return true;
+
               if (m instanceof Backbone.Collection) {
                 return false;
               }
@@ -328,23 +414,38 @@ define([
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            deps: ['auth_source'],
+            editable: function(m) {
+              if (m.get('auth_source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }, {
             id: 'confirmPassword',
             label: gettext('Confirm password'),
             type: 'password',
             disabled: false,
             control: 'input',
-            cell: 'password',
+            cell: PasswordDepCell,
             cellHeaderClasses: 'width_percent_20',
+            editable: function(m) {
+              if (m.get('auth_source') == 'internal') {
+                return true;
+              } else {
+                return false;
+              }
+            },
           }],
           validate: function() {
             var errmsg = null,
               changedAttrs = this.changed || {},
               email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
 
-            if (('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
+            if (this.get('auth_source') == 'internal' && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
                 _.isNull(this.get('email')) ||
                 String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
               errmsg = gettext('Email address cannot be empty.');
@@ -358,9 +459,8 @@ define([
               this.errorModel.set('email', errmsg);
               return errmsg;
             } else if (!!this.get('email') && this.collection.where({
-              'email': this.get('email'),
+              'email': this.get('email'), 'auth_source': 'internal',
             }).length > 1) {
-
               errmsg = gettext('The email address %s already exists.',
                 this.get('email')
               );
@@ -385,111 +485,113 @@ define([
               this.errorModel.unset('role');
             }
 
-            if (this.isNew()) {
-              // Password is compulsory for new user.
-              if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
-                  _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '')) {
-
-                errmsg = gettext('Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+            if (this.get('auth_source') == 'internal') {
+              if (this.isNew()) {
+                // Password is compulsory for new user.
+                if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
+                    _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '')) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                this.get('newPassword').length < 6) {
+                  errmsg = gettext('Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-              }
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
-                  _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == '')) {
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                }
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
+                    _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == '')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-              if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
+                }
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('confirmPassword');
-              }
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
 
-            } else {
-              if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
-                  this.get('newPassword') == '') &&
-                ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
-                  this.get('confirmPassword') == ''))) {
-
-                this.errorModel.unset('newPassword');
-                if (this.get('newPassword') == '') {
-                  this.set({
-                    'newPassword': undefined,
-                  });
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('confirmPassword');
                 }
 
-                this.errorModel.unset('confirmPassword');
-                if (this.get('confirmPassword') == '') {
-                  this.set({
-                    'confirmPassword': undefined,
-                  });
-                }
-              } else if (!_.isUndefined(this.get('newPassword')) &&
-                !_.isNull(this.get('newPassword')) &&
-                !this.get('newPassword') == '' &&
-                this.get('newPassword').length < 6) {
+              } else {
+                if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
+                    this.get('newPassword') == '') &&
+                  ((_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
+                    this.get('confirmPassword') == ''))) {
+
+                  this.errorModel.unset('newPassword');
+                  if (this.get('newPassword') == '') {
+                    this.set({
+                      'newPassword': undefined,
+                    });
+                  }
 
-                errmsg = gettext('Password must be at least 6 characters for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.unset('confirmPassword');
+                  if (this.get('confirmPassword') == '') {
+                    this.set({
+                      'confirmPassword': undefined,
+                    });
+                  }
+                } else if (!_.isUndefined(this.get('newPassword')) &&
+                  !_.isNull(this.get('newPassword')) &&
+                  !this.get('newPassword') == '' &&
+                  this.get('newPassword').length < 6) {
 
-                this.errorModel.set('newPassword', errmsg);
-                return errmsg;
-              } else if (_.isUndefined(this.get('confirmPassword')) ||
-                _.isNull(this.get('confirmPassword')) ||
-                this.get('confirmPassword') == '') {
+                  errmsg = gettext('Password must be at least 6 characters for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Confirm Password cannot be empty for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('newPassword', errmsg);
+                  return errmsg;
+                } else if (_.isUndefined(this.get('confirmPassword')) ||
+                  _.isNull(this.get('confirmPassword')) ||
+                  this.get('confirmPassword') == '') {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
-                this.get('newPassword') != this.get('confirmPassword')) {
+                  errmsg = gettext('Confirm Password cannot be empty for user %s.',
+                    (this.get('email') || '')
+                  );
 
-                errmsg = gettext('Passwords do not match for user %s.',
-                  (this.get('email') || '')
-                );
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
+                  this.get('newPassword') != this.get('confirmPassword')) {
 
-                this.errorModel.set('confirmPassword', errmsg);
-                return errmsg;
-              } else {
-                this.errorModel.unset('newPassword');
-                this.errorModel.unset('confirmPassword');
+                  errmsg = gettext('Passwords do not match for user %s.',
+                    (this.get('email') || '')
+                  );
+
+                  this.errorModel.set('confirmPassword', errmsg);
+                  return errmsg;
+                } else {
+                  this.errorModel.unset('newPassword');
+                  this.errorModel.unset('confirmPassword');
+                }
               }
             }
             return null;
@@ -716,7 +818,10 @@ define([
                   saveUser: function(m) {
                     var d = m.toJSON(true);
 
-                    if (m.isNew() && (!m.get('email') || !m.get('role') ||
+                    if(m.isNew() && m.get('authOnlyInternal') === false &&
+                     (!m.get('username') || !m.get('auth_source') || !m.get('role')) ) {
+                      return false;
+                    } else if (m.isNew() && m.get('authOnlyInternal') === true &&  (!m.get('email') || !m.get('role') ||
                         !m.get('newPassword') || !m.get('confirmPassword') ||
                         m.get('newPassword') != m.get('confirmPassword'))) {
                       // New user model is valid but partially filled so return without saving.
@@ -741,7 +846,7 @@ define([
 
                           m.startNewSession();
                           alertify.success(gettext('User \'%s\' saved.',
-                            m.get('email')
+                            m.get('username')
                           ));
                         },
                         error: function(res, jqxhr) {
@@ -797,6 +902,23 @@ define([
                   }, 100);
                 });
 
+              $.ajax({
+                url: SOURCEURL,
+                method: 'GET',
+                async: false,
+              })
+                .done(function(res) {
+                  Sources = res;
+                })
+                .fail(function() {
+                  setTimeout(function() {
+                    alertify.alert(
+                      gettext('Error'),
+                      gettext('Cannot load user Sources.')
+                    );
+                  }, 100);
+                });
+
               var view = this.view = new Backgrid.Grid({
                 row: UserRow,
                 columns: gridSchema.columns,
diff --git a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
index cfcb77813..c6e210343 100644
--- a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
+++ b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js
@@ -14,6 +14,7 @@ define('pgadmin.user_management.current_user', [], function() {
         'is_admin': {{ is_admin }},
         'name': '{{ name }}',
         'allow_save_password': {{ allow_save_password }},
-        'allow_save_tunnel_password': {{ allow_save_tunnel_password }}
+        'allow_save_tunnel_password': {{ allow_save_tunnel_password }},
+        'auth_sources': {{ auth_sources }}
     }
 });
diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py
index bb3f7da70..42ae510b5 100644
--- a/web/regression/python_test_utils/csrf_test_client.py
+++ b/web/regression/python_test_utils/csrf_test_client.py
@@ -109,7 +109,7 @@ class TestClient(testing.FlaskClient):
             csrf_token = self.generate_csrf_token()
 
         res = self.post(
-            '/login', data=dict(
+            '/authenticate/login', data=dict(
                 email=email, password=password,
                 csrf_token=csrf_token,
             ),
@@ -120,5 +120,5 @@ class TestClient(testing.FlaskClient):
         return res
 
     def logout(self):
-        res = self.get('/logout', follow_redirects=False)
+        res = self.get('/logout?next=/browser/', follow_redirects=False)
         self.csrf_token = None
diff --git a/web/regression/runtests.py b/web/regression/runtests.py
index ef5b46328..fcf73a886 100644
--- a/web/regression/runtests.py
+++ b/web/regression/runtests.py
@@ -118,6 +118,11 @@ app.PGADMIN_RUNTIME = True
 if config.SERVER_MODE is True:
     app.PGADMIN_RUNTIME = False
 app.config['WTF_CSRF_ENABLED'] = True
+
+# Authentication sources
+app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal'
+app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap'
+
 app.test_client_class = TestClient
 test_client = app.test_client()
 test_client.setApp(app)
@@ -195,6 +200,8 @@ def get_test_modules(arguments):
             "browser.tests.test_login",
             "browser.tests.test_logout",
             "browser.tests.test_reset_password",
+            "browser.tests.test_ldap_login",
+            "browser.tests.test_ldap_with_mocking",
         ])
     if arguments['exclude'] is not None:
         exclude_pkgs += arguments['exclude'].split(',')
diff --git a/web/regression/test_config.json.in b/web/regression/test_config.json.in
index 15b133a19..0a151e633 100644
--- a/web/regression/test_config.json.in
+++ b/web/regression/test_config.json.in
@@ -11,6 +11,49 @@
     "login_password": "PASSWORD",
     "login_username": "[email protected]"
   },
+  "pgAdmin4_ldap_credentials": {
+    "login_password": "PASSWORD",
+    "login_username": "USERNAME"
+  },
+  "ldap_config": [
+    {
+    "ldap": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_ssl": {
+      "name": "Ldap scenario name"
+      "uri": "ldaps://IP-ADDRESS/HOSTNAME:636",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": false,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    },
+    "ldap_with_tls": {
+      "name": "Ldap scenario name"
+      "uri": "ldap://IP-ADDRESS/HOSTNAME:389",
+      "base_dn": "BASE-DN",
+      "search_base_dn": "SEARCH-BASE-DN",
+      "username_atr": "UID",
+      "search_filter": "(objectclass=*)",
+      "use_starttls": true,
+      "ca_cert_file": "",
+      "cert_file": "",
+      "key_file": ""
+    }
+  }],
   "server_group": 1,
   "server_credentials": [
     {


^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
@ 2020-04-06 10:28  Akshay Joshi <[email protected]>
  parent: Khushboo Vashi <[email protected]>
  0 siblings, 0 replies; 16+ messages in thread

From: Akshay Joshi @ 2020-04-06 10:28 UTC (permalink / raw)
  To: Khushboo Vashi <[email protected]>; +Cc: Dave Page <[email protected]>; pgadmin-hackers

Thanks, patch applied.

On Fri, Apr 3, 2020 at 2:37 PM Khushboo Vashi <
[email protected]> wrote:

> Hi,
>
> Please find the attached updated patch.
>
> On Fri, Apr 3, 2020 at 1:50 PM Akshay Joshi <[email protected]>
> wrote:
>
>> Hi Khushboo
>>
>> Some more review comments:
>>
>>    - Fix one small PEP8 issue.
>>
>> Fixed.
>
>>
>>    - If ipAddress or Port is not set in the configuration file then
>>    browser showing the following data, it should be shown proper error message
>>    on the login page
>>       - {"success":0,"errormsg":"Port could not be cast to integer value
>>       as '<port>'","info":"","result":null,"data":null}
>>
>> Fixed.
>
>>
>>    - Disable the Username field in the User Management dialog if the
>>    authentication source is set to internal.
>>
>> Done.
>
>>
>>    - API Test cases are failing if LDAP related settings are not
>>    provided in the test_config.json file. If the configuration is not provided
>>    then LDAP tests should be skipped.
>>
>> Fixed.
>
>> @Dave, I have tested and done the code review. Can you please do it once
>> as well, whenever Khushboo will fix and send the updated patch?
>>
>> Thanks,
> Khushboo
>
>>
>> On Thu, Apr 2, 2020 at 7:00 PM Khushboo Vashi <
>> [email protected]> wrote:
>>
>>> Hi Akshay,
>>>
>>> Please find the attached updated patch.
>>>
>>> On Thu, Apr 2, 2020 at 4:55 PM Akshay Joshi <
>>> [email protected]> wrote:
>>>
>>>> Hi Khushboo
>>>>
>>>> Following are the initial review comments (GUI):
>>>>
>>>> *Desktop Mode: *
>>>>
>>>>    - KeyError: '_auth_source_manager_obj' in desktop mode. (*Note*
>>>>    error occurs when the patch has applied and server mode is False.)
>>>>
>>>> Fixed.
>>>
>>>> *Server Mode:*
>>>>
>>>> AUTHENTICATION_SOURCES = ['internal']
>>>>
>>>>
>>>>    - Try to add a new user with the same email address, it throws a
>>>>    unique key constraint error. Validation was there previously before saving
>>>>    it.
>>>>
>>>> Fixed.
>>>
>>>> AUTHENTICATION_SOURCES = ['internal', 'ldap']
>>>>
>>>>    - Try to add a new user with the same email address, it throws
>>>>    unique key constraint error which should not it may possible that the user
>>>>    has the same email address for internal and ldap.
>>>>
>>>> If the source is internal, it will not allow but with ldap, we can add
>>> the user with the same email id.
>>>
>>>> AUTHENTICATION_SOURCES = ['ldap']
>>>>
>>>>    - If ipAddress or Port is not set in the configuration file then
>>>>    browser showing the following data, it should be shown proper error message
>>>>    on the login page
>>>>       - {"success":0,"errormsg":"Port could not be cast to integer
>>>>       value as '<port>'","info":"","result":null,"data":null}
>>>>
>>>> Done
>>>
>>>>
>>>>    - If IP address and port is provided but it is wrong, it shows the
>>>>    following error, can we make a generic error message? Also clicking on the
>>>>    Close button on that error message is not working.
>>>>    [image: Screenshot 2020-04-02 at 4.23.55 PM.png]
>>>>
>>>> I will look into the close button issue as it is an existing issue.
>>>
>>>>
>>>>    -
>>>>    - IP address and port of LDAP server are correct, give wrong user
>>>>    name and password, it shows error "Error binding to the LDAP Server: None".
>>>>    Please correct the appropriate error message.
>>>>
>>>> Fixed.
>>>
>>>>
>>>>    - All the configuration parameter is correct and tries to log in on
>>>>    LDAP server using username (*not email address*) and password got following
>>>>    error:
>>>>
>>>> current_user.email.split('@')[0] if config.SERVER_MODE is True
>>>> AttributeError: 'NoneType' object has no attribute 'split'.
>>>>
>>>> Fixed.
>>>
>>>> Not able to test due to the above error. Please fix and resend the
>>>> patch.
>>>>
>>>
>>> Thanks,
>>> Khushboo
>>>
>>> Thanks,
>>> Khushboo
>>>
>>>>
>>>> On Thu, Apr 2, 2020 at 2:06 PM Khushboo Vashi <
>>>> [email protected]> wrote:
>>>>
>>>>> Hi,
>>>>>
>>>>> Resending the patch.
>>>>> Missed the requirements.txt file in the previous patch.
>>>>>
>>>>> Thanks,
>>>>> Khushboo
>>>>>
>>>>> On Wed, Apr 1, 2020 at 5:38 PM Khushboo Vashi <
>>>>> [email protected]> wrote:
>>>>>
>>>>>> Hi,
>>>>>>
>>>>>> Please find the attached updated patch which includes the review
>>>>>> comments given in the review meeting:
>>>>>>
>>>>>> 1. Do not store password for ldap user in sqlite database
>>>>>> 2. Forgot Password : Give error to ldap users
>>>>>> 3. User Management dialog changes
>>>>>> 4. Authentication source display besides username / email after login
>>>>>>
>>>>>> Thanks,
>>>>>> Khushboo
>>>>>>
>>>>>>
>>>>>> On Tue, Mar 24, 2020 at 3:20 PM Khushboo Vashi <
>>>>>> [email protected]> wrote:
>>>>>>
>>>>>>> Please disregard my previous patch, attached the updated patch. :)
>>>>>>>
>>>>>>>
>>>>>>> On Tue, Mar 24, 2020 at 10:32 AM Khushboo Vashi <
>>>>>>> [email protected]> wrote:
>>>>>>>
>>>>>>>> Please disregard my previous patch, attached the updated patch.
>>>>>>>>
>>>>>>>> On Tue, Mar 24, 2020 at 10:29 AM Khushboo Vashi <
>>>>>>>> [email protected]> wrote:
>>>>>>>>
>>>>>>>>> Hi,
>>>>>>>>>
>>>>>>>>> Please find the attached updated patch.
>>>>>>>>>
>>>>>>>>>
>>>>>>>>> On Tue, Mar 17, 2020 at 4:11 PM Dave Page <[email protected]>
>>>>>>>>> wrote:
>>>>>>>>>
>>>>>>>>>> Hi
>>>>>>>>>>
>>>>>>>>>> On Tue, Mar 17, 2020 at 10:24 AM Khushboo Vashi <
>>>>>>>>>> [email protected]> wrote:
>>>>>>>>>>
>>>>>>>>>>> Hi Dave,
>>>>>>>>>>>
>>>>>>>>>>> Thanks for the review.
>>>>>>>>>>>
>>>>>>>>>>> On Tue, Mar 17, 2020 at 3:42 PM Dave Page <[email protected]>
>>>>>>>>>>> wrote:
>>>>>>>>>>>
>>>>>>>>>>>> Hi
>>>>>>>>>>>>
>>>>>>>>>>>> 30 second read of the first version of the patch...
>>>>>>>>>>>>
>>>>>>>>>>>> - Please move the configuration into config.py. Users should
>>>>>>>>>>>> never have to modify a distributed file (it messes up packaging). I don't
>>>>>>>>>>>> see any reason to use a different file just for auth config.
>>>>>>>>>>>>
>>>>>>>>>>>> There are many settings for the LDAP, and in the future we will
>>>>>>>>>>> add other external sources also, so I thought it would be better if we have
>>>>>>>>>>> different file for the authentication.
>>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>> Sure, but our config file is small compared to many. Splitting
>>>>>>>>>> things out is more confusing for users. If they want to do that themselves
>>>>>>>>>> of course, they can add a config_local.py file which includes other files
>>>>>>>>>> as needed.
>>>>>>>>>>
>>>>>>>>> Fixed.
>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>>> - I think all config options should be prefixed with LDAP_ as we
>>>>>>>>>>>> may have things like CERT_FILE for other purposes too.
>>>>>>>>>>>>
>>>>>>>>>>>> Sure.
>>>>>>>>>>>
>>>>>>>>>> Done.
>>>>>>>>>
>>>>>>>>>> - I don't see any test cases.
>>>>>>>>>>>>
>>>>>>>>>>>> I will think about this, as right now no idea how to write test
>>>>>>>>>>> cases for this.
>>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>> It should be fairly straightforward to write tests for some of
>>>>>>>>>> the functions in the auth classes. For testing the actual LDAP stuff, we
>>>>>>>>>> probably need to add LDAP config options to test_config.json, and only if
>>>>>>>>>> present, run the tests. That would probably need to support a list of LDAP
>>>>>>>>>> servers, so we can test with different configurations (LDAP, LDAPS,
>>>>>>>>>> LDAP_STARTTLS, AD etc).
>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>> Done.
>>>>>>>>>
>>>>>>>>> Thanks,
>>>>>>>>> Khushboo
>>>>>>>>>
>>>>>>>>>> Thanks.
>>>>>>>>>>>>
>>>>>>>>>>>> Thanks,
>>>>>>>>>>> Khushboo
>>>>>>>>>>>
>>>>>>>>>>>>
>>>>>>>>>>>> On Tue, Mar 17, 2020 at 8:55 AM Khushboo Vashi <
>>>>>>>>>>>> [email protected]> wrote:
>>>>>>>>>>>>
>>>>>>>>>>>>> Hi,
>>>>>>>>>>>>>
>>>>>>>>>>>>> Please find the attached patch to support LDAP Authentication
>>>>>>>>>>>>> in Server mode.
>>>>>>>>>>>>> To test the patch, config_auth.py needs to be configured for
>>>>>>>>>>>>> LDAP configurations. The config settings are explained in this file in
>>>>>>>>>>>>> detail. After configuring the parameters, start the pgadmin server in
>>>>>>>>>>>>> Server mode and connect with LDAP server with the valid user via login page.
>>>>>>>>>>>>>
>>>>>>>>>>>>> I have tested this patch with ldap and ldap + ssl/tls. With
>>>>>>>>>>>>> the TLS, I have used the default config of ldap3 without certificates.
>>>>>>>>>>>>>
>>>>>>>>>>>>> @Dave, can you please review this patch, as you have a better
>>>>>>>>>>>>> understanding of LDAP and you can easily pointed out if I have missed
>>>>>>>>>>>>> anything.
>>>>>>>>>>>>>
>>>>>>>>>>>>> Note: For the document update I will create the task and
>>>>>>>>>>>>> assign to Nidhi for the same.
>>>>>>>>>>>>>
>>>>>>>>>>>>> Thanks,
>>>>>>>>>>>>> Khushboo
>>>>>>>>>>>>>
>>>>>>>>>>>>
>>>>>>>>>>>>
>>>>>>>>>>>> --
>>>>>>>>>>>> Dave Page
>>>>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>>>>
>>>>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>>>>
>>>>>>>>>>>
>>>>>>>>>>
>>>>>>>>>> --
>>>>>>>>>> Dave Page
>>>>>>>>>> Blog: http://pgsnake.blogspot.com
>>>>>>>>>> Twitter: @pgsnake
>>>>>>>>>>
>>>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com
>>>>>>>>>> The Enterprise PostgreSQL Company
>>>>>>>>>>
>>>>>>>>>
>>>>
>>>> --
>>>> *Thanks & Regards*
>>>> *Akshay Joshi*
>>>>
>>>> *Sr. Software Architect*
>>>> *EnterpriseDB Software India Private Limited*
>>>> *Mobile: +91 976-788-8246*
>>>>
>>>
>>
>> --
>> *Thanks & Regards*
>> *Akshay Joshi*
>>
>> *Sr. Software Architect*
>> *EnterpriseDB Software India Private Limited*
>> *Mobile: +91 976-788-8246*
>>
>

-- 
*Thanks & Regards*
*Akshay Joshi*

*Sr. Software Architect*
*EnterpriseDB Software India Private Limited*
*Mobile: +91 976-788-8246*


^ permalink  raw  reply  [nested|flat] 16+ messages in thread


end of thread, other threads:[~2020-04-06 10:28 UTC | newest]

Thread overview: 16+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2020-03-17 08:55 [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP] Khushboo Vashi <[email protected]>
2020-03-17 10:06 ` navnath gadakh <[email protected]>
2020-03-17 10:24   ` Khushboo Vashi <[email protected]>
2020-03-17 10:12 ` Dave Page <[email protected]>
2020-03-17 10:23   ` Khushboo Vashi <[email protected]>
2020-03-17 10:41     ` Dave Page <[email protected]>
2020-03-24 04:59       ` Khushboo Vashi <[email protected]>
2020-03-24 05:02         ` Khushboo Vashi <[email protected]>
2020-03-24 09:50           ` Khushboo Vashi <[email protected]>
2020-04-01 12:08             ` Khushboo Vashi <[email protected]>
2020-04-02 08:36               ` Khushboo Vashi <[email protected]>
2020-04-02 11:25                 ` Akshay Joshi <[email protected]>
2020-04-02 13:30                   ` Khushboo Vashi <[email protected]>
2020-04-03 08:19                     ` Akshay Joshi <[email protected]>
2020-04-03 09:07                       ` Khushboo Vashi <[email protected]>
2020-04-06 10:28                         ` Akshay Joshi <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox