public inbox for [email protected]  
help / color / mirror / Atom feed
From: Khushboo Vashi <[email protected]>
To: pgadmin-hackers <[email protected]>
Subject: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
Date: Tue, 17 Mar 2020 14:25:38 +0530
Message-ID: <CAFOhELeCi7Dyp_T5EeKQXWLc1H580u5o13BKQeFxAZPpjRmhTw@mail.gmail.com> (raw)

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.');


view thread (16+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected]
  Subject: Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
  In-Reply-To: <CAFOhELeCi7Dyp_T5EeKQXWLc1H580u5o13BKQeFxAZPpjRmhTw@mail.gmail.com>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

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