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://: or ldap://: +SERVER_URI = 'ldap://:' + +# 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 = '' + +# The LDAP attribute containing user names. In OpenLDAP, this may be 'uid' +# whilst in AD, 'sAMAccountName' might be appropriate. (REQUIRED) +USERNAME_ATTRIBUTE = '' + +# Search ldap for further authentication +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='') + + +@blueprint.route('/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 + + +@six.add_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){