diff --git a/docs/en_US/getting_started.rst b/docs/en_US/getting_started.rst index 700e8d48b..92967847e 100644 --- a/docs/en_US/getting_started.rst +++ b/docs/en_US/getting_started.rst @@ -39,6 +39,7 @@ Mode is pre-configured for security. ldap kerberos oauth2 + webserver .. note:: Pre-compiled and configured installation packages are available for diff --git a/docs/en_US/release_notes_6_1.rst b/docs/en_US/release_notes_6_1.rst index 9ec4d7fec..ac16e3e59 100644 --- a/docs/en_US/release_notes_6_1.rst +++ b/docs/en_US/release_notes_6_1.rst @@ -11,6 +11,7 @@ New features | `Issue #6081 `_ - Added support for advanced table fields like the foreign key, primary key in the ERD tool. | `Issue #6529 `_ - Added index creation when generating SQL in the ERD tool. +| `Issue #6657 `_ - Added support for authentication via the webserver (REMOTE_USER). Housekeeping ************ @@ -20,6 +21,7 @@ Bug fixes ********* | `Issue #6754 `_ - Ensure that query highlighting color in the query tool should be less intensive. +| `Issue #6719 `_ - Fixed OAuth2 integration redirect issue. | `Issue #6797 `_ - Remove an extra blank line at the start of the SQL for function, procedure, and trigger function. | `Issue #6828 `_ - Fixed an issue where the tree is not scrolling to the object selected from the search result. | `Issue #6882 `_ - Ensure that columns should be displayed in the order of creation instead of alphabetical order in the browser tree. diff --git a/docs/en_US/webserver.rst b/docs/en_US/webserver.rst new file mode 100644 index 000000000..abd0cf6c4 --- /dev/null +++ b/docs/en_US/webserver.rst @@ -0,0 +1,44 @@ +.. _webserver: + +******************************************** +`Enabling Webserver Authentication`:index: +******************************************** + +To configure Webserver authentication, you must setup your webserver +with any authentication plug-in (such as Shibboleth, HTTP BASIC auth) +as long as it sets the REMOTE_USER environment variable. +To enable Webserver authentication for pgAdmin, you must configure the Webserver +settings in the *config_local.py* or *config_system.py* file (see the +:ref:`config.py ` documentation) on the system where pgAdmin is +installed in Server mode. You can copy these settings from *config.py* file +and modify the values for the following parameters: + + +.. csv-table:: + :header: "**Parameter**", "**Description**" + :class: longtable + :widths: 35, 55 + + "AUTHENTICATION_SOURCES", "The default value for this parameter is *internal*. + To enable OAUTH2 authentication, you must include *webserver* in the list of values + for this parameter. you can modify the value as follows: + + * [‘webserver’]: pgAdmin will use only Webserver authentication. + + * [‘webserver’, ‘internal’]: pgAdmin will first try to authenticate the user + through webserver. If that authentication fails, then it will return back + to the login dialog where you need to provide internal pgAdmin user + credentials for authentication." + "WEBSERVER_AUTO_CREATE_USER", "Set the value to *True* if you want to automatically + create a pgAdmin user corresponding to a successfully authenticated Webserver user. + Please note that password is not stored in the pgAdmin database." + + +Master Password +=============== + +In the multi user mode, pgAdmin uses user's login password to encrypt/decrypt the PostgreSQL server password. +In the Webserver authentication, the pgAdmin does not store the user's password, so we need an encryption key to store +the PostgreSQL server password. +To accomplish this, set the configuration parameter MASTER_PASSWORD to *True*, so upon setting the master password, +it will be used as an encryption key while storing the password. If it is False, the server password can not be stored. diff --git a/web/config.py b/web/config.py index 7a1f4ab1f..db313273e 100644 --- a/web/config.py +++ b/web/config.py @@ -570,7 +570,8 @@ ENHANCED_COOKIE_PROTECTION = True # Default setting is internal # External Supported Sources: ldap, kerberos, oauth2 # Multiple authentication can be achieved by setting this parameter to -# ['ldap', 'internal'] or ['oauth2', 'internal'] etc. +# ['ldap', 'internal'] or ['oauth2', 'internal'] or +# ['webserver', 'internal'] etc. # pgAdmin will authenticate the user with ldap/oauth2 whatever first in the # list, in case of failure the second authentication option will be considered. @@ -729,6 +730,12 @@ OAUTH2_CONFIG = [ OAUTH2_AUTO_CREATE_USER = True +########################################################################## +# Webserver Configuration +########################################################################## + +WEBSERVER_AUTO_CREATE_USER = True + ########################################################################## # PSQL tool settings ########################################################################## diff --git a/web/pgAdmin4.wsgi b/web/pgAdmin4.wsgi index 973d2701d..95aab8de2 100644 --- a/web/pgAdmin4.wsgi +++ b/web/pgAdmin4.wsgi @@ -13,6 +13,8 @@ import sys if sys.version_info < (3, 4): raise Exception('This application must be run under Python 3.4 or later.') +os.environ['SCRIPT_NAME'] = '/pgadmin4' + import builtins root = os.path.dirname(os.path.realpath(__file__)) diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 37eb26ccc..8047b3417 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -46,7 +46,7 @@ from pgadmin.utils.ajax import internal_server_error, make_json_response from pgadmin.utils.csrf import pgCSRFProtect from pgadmin import authenticate from pgadmin.utils.security_headers import SecurityHeaders -from pgadmin.utils.constants import KERBEROS, OAUTH2, INTERNAL, LDAP +from pgadmin.utils.constants import KERBEROS, OAUTH2, INTERNAL, LDAP, WEBSERVER # Explicitly set the mime-types so that a corrupted windows registry will not # affect pgAdmin 4 to be load properly. This will avoid the issues that may @@ -470,11 +470,17 @@ def create_app(app_name=None): 'SECURITY_EMAIL_VALIDATOR_ARGS': config.SECURITY_EMAIL_VALIDATOR_ARGS })) + if 'SCRIPT_NAME' in os.environ and os.environ["SCRIPT_NAME"]: + app.config.update(dict({ + 'APPLICATION_ROOT': os.environ["SCRIPT_NAME"] + })) + app.config.update(dict({ 'INTERNAL': INTERNAL, 'LDAP': LDAP, 'KERBEROS': KERBEROS, - 'OAUTH2': OAUTH2 + 'OAUTH2': OAUTH2, + 'WEBSERVER': WEBSERVER })) security.init_app(app, user_datastore) @@ -771,15 +777,14 @@ def create_app(app_name=None): elif config.SERVER_MODE and \ not current_user.is_authenticated and \ request.endpoint in ('redirects.index', 'security.login'): - if app.PGADMIN_EXTERNAL_AUTH_SOURCE == KERBEROS: + if app.PGADMIN_EXTERNAL_AUTH_SOURCE in [KERBEROS, WEBSERVER]: return authenticate.login() # if the server is restarted the in memory key will be lost # but the user session may still be active. Logout the user # to get the key again when login if config.SERVER_MODE and current_user.is_authenticated and \ - app.PGADMIN_EXTERNAL_AUTH_SOURCE != \ - KERBEROS and app.PGADMIN_EXTERNAL_AUTH_SOURCE != \ - OAUTH2 and\ + app.PGADMIN_EXTERNAL_AUTH_SOURCE not in [ + KERBEROS, OAUTH2, WEBSERVER] and \ current_app.keyManager.get() is None and \ request.endpoint not in ('security.login', 'security.logout'): logout_user() diff --git a/web/pgadmin/authenticate/webserver.py b/web/pgadmin/authenticate/webserver.py new file mode 100644 index 000000000..47af8becd --- /dev/null +++ b/web/pgadmin/authenticate/webserver.py @@ -0,0 +1,119 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""A blueprint module implementing the Webserver authentication.""" + +import random +import string +import config +from flask import request, current_app, session, Response, render_template, \ + url_for +from flask_babelex import gettext +from flask_security import login_user +from .internal import BaseAuthentication +from pgadmin.model import User +from pgadmin.tools.user_management import create_user +from pgadmin.utils.constants import WEBSERVER +from pgadmin.utils import PgAdminModule +from pgadmin.utils.csrf import pgCSRFProtect +from flask_security.utils import logout_user +from os import environ, path, remove + + +class WebserverModule(PgAdminModule): + def register(self, app, options, first_registration=False): + # Do not look for the sub_modules, + # instead call blueprint.register(...) directly + super(PgAdminModule, self).register(app, options, first_registration) + + def get_exposed_url_endpoints(self): + return ['webserver.login', + 'webserver.logout'] + + +def init_app(app): + MODULE_NAME = 'webserver' + + blueprint = WebserverModule(MODULE_NAME, __name__, static_url_path='') + + @blueprint.route("/login", + endpoint="login", methods=["GET"]) + @pgCSRFProtect.exempt + def webserver_login(): + logout_user() + return Response(render_template("browser/kerberos_login.html", + login_url=url_for('security.login'), + )) + + @blueprint.route("/logout", + endpoint="logout", methods=["GET"]) + @pgCSRFProtect.exempt + def webserver_logout(): + logout_user() + return Response(render_template("browser/kerberos_logout.html", + login_url=url_for('security.login'), + )) + + app.register_blueprint(blueprint) + + +class WebserverAuthentication(BaseAuthentication): + LOGIN_VIEW = 'webserver.login' + LOGOUT_VIEW = 'webserver.logout' + + def get_source_name(self): + return WEBSERVER + + def get_friendly_name(self): + return gettext("webserver") + + def validate(self, form): + return True + + def get_user(self): + return request.environ.get('REMOTE_USER') + + def authenticate(self, form): + username = self.get_user() + + if not username: + return False, gettext( + "Webserver authenticate failed.") + + session['pass_enc_key'] = ''.join( + (random.choice(string.ascii_lowercase) for x in range(10))) + useremail = request.environ.get('mail') + if not useremail: + useremail = '' + return self.__auto_create_user(username, '') + + def login(self, form): + username = self.get_user() + if username: + user = User.query.filter_by(username=username).first() + status = login_user(user) + if not status: + current_app.logger.exception(self.messages('LOGIN_FAILED')) + return False, self.messages('LOGIN_FAILED') + return True, None + return False, self.messages('LOGIN_FAILED') + + def __auto_create_user(self, username, useremail): + """Add the webserver user to the internal SQLite database.""" + if config.WEBSERVER_AUTO_CREATE_USER: + user = User.query.filter_by(username=username).first() + if not user: + return create_user({ + 'username': username, + 'email': useremail, + 'role': 2, + 'active': True, + 'auth_source': WEBSERVER + }) + return True, None diff --git a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py index 0f49c444d..982e7bcde 100644 --- a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py +++ b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py @@ -133,4 +133,5 @@ class KerberosLoginMockTestCase(BaseTestGenerator): """ cls.tester.logout() app_config.AUTHENTICATION_SOURCES = [INTERNAL] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py index 51b512afd..ec2c7d025 100644 --- a/web/pgadmin/browser/tests/test_ldap_login.py +++ b/web/pgadmin/browser/tests/test_ldap_login.py @@ -95,4 +95,5 @@ class LDAPLoginTestCase(BaseTestGenerator): """ cls.tester.logout() app_config.AUTHENTICATION_SOURCES = [INTERNAL] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = 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 index 38c6b4724..19b548cd4 100644 --- a/web/pgadmin/browser/tests/test_ldap_with_mocking.py +++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py @@ -81,4 +81,5 @@ class LDAPLoginMockTestCase(BaseTestGenerator): """ cls.tester.logout() app_config.AUTHENTICATION_SOURCES = [INTERNAL] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_login.py b/web/pgadmin/browser/tests/test_login.py index 451c05b64..f5ce10927 100644 --- a/web/pgadmin/browser/tests/test_login.py +++ b/web/pgadmin/browser/tests/test_login.py @@ -100,6 +100,7 @@ class LoginTestCase(BaseTestGenerator): # No need to call base class setup function def setUp(self): app_config.AUTHENTICATION_SOURCES = [INTERNAL] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL def runTest(self): """This function checks login functionality.""" diff --git a/web/pgadmin/browser/tests/test_oauth2_with_mocking.py b/web/pgadmin/browser/tests/test_oauth2_with_mocking.py index 71706ebe6..0ab6f7cdd 100644 --- a/web/pgadmin/browser/tests/test_oauth2_with_mocking.py +++ b/web/pgadmin/browser/tests/test_oauth2_with_mocking.py @@ -145,4 +145,5 @@ class Oauth2LoginMockTestCase(BaseTestGenerator): """ cls.tester.logout() app_config.AUTHENTICATION_SOURCES = [INTERNAL] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_webserver_with_mocking.py b/web/pgadmin/browser/tests/test_webserver_with_mocking.py new file mode 100644 index 000000000..74343d135 --- /dev/null +++ b/web/pgadmin/browser/tests/test_webserver_with_mocking.py @@ -0,0 +1,86 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, 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 pgadmin.authenticate.registry import AuthSourceRegistry +from unittest.mock import patch, MagicMock +from pgadmin.authenticate import AuthSourceManager +from pgadmin.utils.constants import OAUTH2, LDAP, INTERNAL, WEBSERVER +from flask import request + + +class WebserverLoginMockTestCase(BaseTestGenerator): + """ + This class checks oauth2 login functionality by mocking + Webserver Authentication. + """ + + scenarios = [ + ('Webserver Authentication', dict( + auth_source=[WEBSERVER], + username='test_mock_webserver_user' + )), + ] + + @classmethod + def setUpClass(cls): + """ + We need to logout the test client as we are testing + spnego/kerberos login scenarios. + """ + cls.tester.logout() + + def setUp(self): + app_config.AUTHENTICATION_SOURCES = self.auth_source + self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = WEBSERVER + + def runTest(self): + """This function checks webserver login functionality.""" + if app_config.SERVER_MODE is False: + self.skipTest( + "Can not run Webserver Authentication in the Desktop mode." + ) + + self.test_webserver_authentication() + + def test_webserver_authentication(self): + """ + Ensure that when the client sends an correct authorization token, + they receive a 200 OK response and the user principal is extracted and + passed on to the routed method. + """ + + # Mock Oauth2 Authenticate + AuthSourceRegistry._registry[WEBSERVER].get_user = MagicMock( + return_value=self.username) + + res = self.tester.login(None, + None, + True, + None + ) + self.assertEqual(res.status_code, 200) + respdata = 'Gravatar image for %s' % self.username + self.assertTrue(respdata in res.data.decode('utf8')) + + def tearDown(self): + pass + + @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] + app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL + utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index 91931c422..96d46367b 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -55,12 +55,14 @@ ERROR_FETCHING_DATA = gettext('Unable to fetch data.') INTERNAL = 'internal' LDAP = 'ldap' KERBEROS = 'kerberos' -OAUTH2 = "oauth2" +OAUTH2 = 'oauth2' +WEBSERVER = 'webserver' SUPPORTED_AUTH_SOURCES = [INTERNAL, LDAP, KERBEROS, - OAUTH2] + OAUTH2, + WEBSERVER] BINARY_PATHS = { "as_bin_paths": [