public inbox for [email protected]
help / color / mirror / Atom feedFrom: Khushboo Vashi <[email protected]>
To: Dave Page <[email protected]>
Cc: pgadmin-hackers <[email protected]>
Subject: Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
Date: Wed, 1 Apr 2020 17:38:39 +0530
Message-ID: <CAFOhELdPt8xmSGB33g717-GMKAT=GOZM=g9tfcx2MWORR_44sw@mail.gmail.com> (raw)
In-Reply-To: <CAFOhELdzn5OM0Y=Aa6zGuUJ2B8JRLjvwhHDCK0ebux3LYu_-LQ@mail.gmail.com>
References: <CAFOhELeCi7Dyp_T5EeKQXWLc1H580u5o13BKQeFxAZPpjRmhTw@mail.gmail.com>
<CA+OCxoyZvHAxNTDXYDNVE0irEFwYnJ0z-5KXKuJ_1Bc9aEYTWA@mail.gmail.com>
<CAFOhELeMo+DhRdbaL462jhrCOcxLE1vnbMfpVADf=x4S9NPSmQ@mail.gmail.com>
<CA+OCxoxeMw9cu8o3qGHPnvQUZYT-LPS5zycyfi0xAwtivErr5w@mail.gmail.com>
<CAFOhELed-cmiprwR8D0oXzvSXgLLu6TK5BLoQnz1Un911t7mnw@mail.gmail.com>
<CAFOhELeiVMiLy6vTGUpZ-9N=TLig5_OcBZxLSC=bUUSviV88VQ@mail.gmail.com>
<CAFOhELdzn5OM0Y=Aa6zGuUJ2B8JRLjvwhHDCK0ebux3LYu_-LQ@mail.gmail.com>
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": [
{
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], [email protected]
Subject: Re: [pgAdmin4][Patch] - RM 2186 - Support external authentication sources [LDAP]
In-Reply-To: <CAFOhELdPt8xmSGB33g717-GMKAT=GOZM=g9tfcx2MWORR_44sw@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