diff --git a/requirements.txt b/requirements.txt index 0fe9c88bd..4675a0b94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,3 +42,4 @@ bcrypt<=3.1.7; cryptography<=3.0; sshtunnel>=0.1.5 ldap3>=2.5.1 +gssapi>=1.6.11 diff --git a/web/config.py b/web/config.py index 086083a52..b8b3714ab 100644 --- a/web/config.py +++ b/web/config.py @@ -535,7 +535,7 @@ ENHANCED_COOKIE_PROTECTION = True ########################################################################## # Default setting is internal -# External Supported Sources: ldap +# External Supported Sources: ldap, kerberos # 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. @@ -618,6 +618,26 @@ LDAP_CA_CERT_FILE = '' LDAP_CERT_FILE = '' LDAP_KEY_FILE = '' + +########################################################################## +# Kerberos Configuration +########################################################################## + +KRB_APP_HOST_NAME = DEFAULT_SERVER + +# If the default_keytab_name is not set in krb5.conf or +# the KRB_KTNAME environment variable is not set then, explicitly set +# the Keytab file + +KRB_KTNAME = '' + +# After kerberos 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. + +KRB_AUTO_CREATE_USER = True + ########################################################################## # Local config settings ########################################################################## diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py index 8e0eb99d3..5586875df 100644 --- a/web/pgAdmin4.py +++ b/web/pgAdmin4.py @@ -97,15 +97,19 @@ if config.SERVER_MODE: # Authentication sources app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal' -app.PGADMIN_SUPPORTED_AUTH_SOURCE = ['internal', 'ldap'] +app.PGADMIN_LDAP_AUTH_SOURCE = 'ldap' +app.PGADMIN_KERBEROS_AUTH_SOURCE = 'kerberos' + +app.PGADMIN_SUPPORTED_AUTH_SOURCE = [app.PGADMIN_DEFAULT_AUTH_SOURCE, + app.PGADMIN_LDAP_AUTH_SOURCE, + app.PGADMIN_KERBEROS_AUTH_SOURCE + ] + 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) - # Start the web server. The port number should have already been set by the # runtime if we're running in desktop mode, otherwise we'll just use the # Flask default. diff --git a/web/pgAdmin4.wsgi b/web/pgAdmin4.wsgi index 4ed2d7860..693f688ae 100644 --- a/web/pgAdmin4.wsgi +++ b/web/pgAdmin4.wsgi @@ -24,6 +24,10 @@ builtins.SERVER_MODE = True import config + +config.AUTHENTICATION_SOURCES = ['kerberos'] +config.KERBEROS_AUTO_CREATE_USER = True + # When running it as a WSGI application, directory for the configuration file # must present. if not os.path.exists(os.path.dirname(config.SQLITE_PATH)): diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 223c2053a..5dc5a1e60 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -674,6 +674,7 @@ def create_app(app_name=None): # Check the auth key is valid, if it's set, and we're not in server # mode, and it's not a help file request. + if not config.SERVER_MODE and app.PGADMIN_INT_KEY != '' and (( 'key' not in request.args or request.args['key'] != app.PGADMIN_INT_KEY) and @@ -695,11 +696,19 @@ def create_app(app_name=None): ) abort(401) login_user(user) + elif config.SERVER_MODE and\ + app.PGADMIN_EXTERNAL_AUTH_SOURCE ==\ + app.PGADMIN_KERBEROS_AUTH_SOURCE and \ + not current_user.is_authenticated and \ + request.endpoint in ('redirects.index', 'security.login'): + 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 != \ + app.PGADMIN_KERBEROS_AUTH_SOURCE and \ current_app.keyManager.get() is None and \ request.endpoint not in ('security.login', 'security.logout'): logout_user() diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py index 5284e0c52..898daf0b1 100644 --- a/web/pgadmin/authenticate/__init__.py +++ b/web/pgadmin/authenticate/__init__.py @@ -11,7 +11,7 @@ import flask import pickle -from flask import current_app, flash +from flask import current_app, flash, Response, request, url_for from flask_babelex import gettext from flask_security import current_user from flask_security.views import _security, _ctx @@ -56,15 +56,24 @@ def login(): if status: # Login the user status, msg = auth_obj.login() + current_auth_obj = auth_obj.as_dict() if not status: + if current_auth_obj['current_source'] ==\ + current_app.PGADMIN_KERBEROS_AUTH_SOURCE: + return flask.redirect('{0}?next={1}'.format(url_for( + 'browser.kerberos_login'), url_for('browser.index'))) + flash(gettext(msg), 'danger') return flask.redirect(get_post_logout_redirect()) - session['_auth_source_manager_obj'] = auth_obj.as_dict() + session['_auth_source_manager_obj'] = current_auth_obj return flask.redirect(get_post_login_redirect()) + elif isinstance(msg, Response): + return msg flash(gettext(msg), 'danger') - return flask.redirect(get_post_logout_redirect()) + response = flask.redirect(get_post_logout_redirect()) + return response class AuthSourceManager(): @@ -75,6 +84,7 @@ class AuthSourceManager(): self.auth_sources = sources self.source = None self.source_friendly_name = None + self.current_source = None def as_dict(self): """ @@ -84,9 +94,17 @@ class AuthSourceManager(): res = dict() res['source_friendly_name'] = self.source_friendly_name res['auth_sources'] = self.auth_sources + res['current_source'] = self.current_source return res + def set_current_source(self, source): + self.current_source = source + + @property + def get_current_source(self, source): + return self.current_source + def set_source(self, source): self.source = source @@ -115,9 +133,34 @@ class AuthSourceManager(): msg = None for src in self.auth_sources: source = get_auth_sources(src) + current_app.logger.debug( + "Authentication initiated via source: %s" % + source.get_source_name()) + + # if self.form.data['email'] and self.form.data['password'] and \ + # source.get_source_name() ==\ + # current_app.PGADMIN_KERBEROS_AUTH_SOURCE: + # continue + status, msg = source.authenticate(self.form) + + # When server sends Unauthorized header to get the ticket over HTTP + # OR When kerberos authentication failed while accessing pgadmin, + # we need to break the loop as no need to authenticate further + # even if the authentication sources set to multiple + if not status: + if (hasattr(msg, 'status') and + msg.status == '401 UNAUTHORIZED') or\ + (source.get_source_name() == + current_app.PGADMIN_KERBEROS_AUTH_SOURCE and + request.method == 'GET'): + break + if status: self.set_source(source) + self.set_current_source(source.get_source_name()) + if msg is not None and 'username' in msg: + self.form._fields['email'].data = msg['username'] return status, msg return status, msg @@ -125,6 +168,9 @@ class AuthSourceManager(): status, msg = self.source.login(self.form) if status: self.set_source_friendly_name(self.source.get_friendly_name()) + current_app.logger.debug( + "Authentication and Login successfully done via source : %s" % + self.source.get_source_name()) return status, msg diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py index 105e78c4e..34361d6fd 100644 --- a/web/pgadmin/authenticate/internal.py +++ b/web/pgadmin/authenticate/internal.py @@ -31,7 +31,11 @@ class BaseAuthentication(object): 'INVALID_EMAIL': gettext('Email/Username is not valid') } - @abstractproperty + @abstractmethod + def get_source_name(self): + pass + + @abstractmethod def get_friendly_name(self): pass @@ -82,6 +86,9 @@ class BaseAuthentication(object): class InternalAuthentication(BaseAuthentication): + def get_source_name(self): + return current_app.PGADMIN_DEFAULT_AUTH_SOURCE + def get_friendly_name(self): return gettext("internal") diff --git a/web/pgadmin/authenticate/kerberos.py b/web/pgadmin/authenticate/kerberos.py new file mode 100644 index 000000000..9be5e0825 --- /dev/null +++ b/web/pgadmin/authenticate/kerberos.py @@ -0,0 +1,137 @@ +########################################################################## +# +# 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 Spnego/Kerberos authentication.""" + +import base64 +import gssapi +from os import environ + +from werkzeug.datastructures import Headers +from flask_babelex import gettext +from flask import Flask, request, Response, session,\ + current_app, render_template, flash + +import config +from pgadmin.model import User, ServerGroup, db, Role +from pgadmin.tools.user_management import create_user + +from flask_security.views import _security, _commit, _ctx +from werkzeug.datastructures import MultiDict + +from .internal import BaseAuthentication + + +# Set the Kerberos config file +if config.KRB_KTNAME and config.KRB_KTNAME != '': + environ['KRB5_KTNAME'] = config.KRB_KTNAME + + +class KERBEROSAuthentication(BaseAuthentication): + + def get_source_name(self): + return current_app.PGADMIN_KERBEROS_AUTH_SOURCE + + def get_friendly_name(self): + return gettext("kerberos") + + def validate(self, form): + return True + + def authenticate(self, frm): + retval = [True, None] + negotiate = False + headers = Headers() + authorization = request.headers.get("Authorization", None) + form_class = _security.login_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + try: + if authorization is not None: + auth_header = authorization.split() + if auth_header[0] == 'Negotiate': + status, negotiate = self.negotiate_start(auth_header[1]) + + if status: + # Saving the first 15 characters of the kerberos key + # to encrypt/decrypt database password + session['kerberos_key'] = auth_header[1][0:15] + # Create user + retval = self.__auto_create_user( + str(negotiate.initiator_name)) + elif isinstance(negotiate, Exception): + flash(gettext(negotiate), 'danger') + retval = [status, + Response(render_template( + "security/login_user.html", + login_user_form=form))] + else: + headers.add('WWW-Authenticate', 'Negotiate ' + + str(base64.b64encode(negotiate), 'utf-8')) + return False, Response("Success", 200, headers) + else: + flash(gettext("Kerberos authentication failed." + " Couldn't find kerberos ticket."), 'danger') + headers.add('WWW-Authenticate', 'Negotiate') + retval = [False, + Response(render_template( + "security/login_user.html", + login_user_form=form), 401, headers)] + finally: + if negotiate is not False: + self.negotiate_end(negotiate) + return retval + + def negotiate_start(self, in_token): + svc_princ = gssapi.Name('HTTP@%s' % config.KRB_APP_HOST_NAME, + name_type=gssapi.NameType.hostbased_service) + cname = svc_princ.canonicalize(gssapi.MechType.kerberos) + + try: + server_creds = gssapi.Credentials(usage='accept', name=cname) + context = gssapi.SecurityContext(creds=server_creds) + out_token = context.step(base64.b64decode(in_token)) + except Exception as e: + current_app.logger.exception(e) + return False, e + + if out_token and not context.complete: + return False, out_token + if context.complete: + return True, context + else: + return False, None + + def negotiate_end(self, context): + # Free gss_cred_id_t + del_creds = getattr(context, 'delegated_creds', None) + if del_creds: + deleg_creds = context.delegated_creds + del(deleg_creds) + + def __auto_create_user(self, username): + """Add the ldap user to the internal SQLite database.""" + username = str(username) + if config.KRB_AUTO_CREATE_USER: + user = User.query.filter_by( + username=username).first() + if user is None: + return create_user({ + 'username': username, + 'email': username, + 'role': 2, + 'active': True, + 'auth_source': 'kerberos' + }) + + return True, {'username': username} diff --git a/web/pgadmin/authenticate/ldap.py b/web/pgadmin/authenticate/ldap.py index 2cdca8605..7c10143c0 100644 --- a/web/pgadmin/authenticate/ldap.py +++ b/web/pgadmin/authenticate/ldap.py @@ -31,6 +31,9 @@ ERROR_SEARCHING_LDAP_DIRECTORY = "Error searching the LDAP directory: {}" class LDAPAuthentication(BaseAuthentication): """Ldap Authentication Class""" + def get_source_name(self): + return current_app.PGADMIN_LDAP_AUTH_SOURCE + def get_friendly_name(self): return gettext("ldap") diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 77120cd59..dd4807b51 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -29,7 +29,7 @@ from flask_security.recoverable import reset_password_token_status, \ generate_reset_password_token, update_password from flask_security.signals import reset_password_instructions_sent from flask_security.utils import config_value, do_flash, get_url, \ - get_message, slash_url_suffix, login_user, send_mail + get_message, slash_url_suffix, login_user, send_mail, logout_user from flask_security.views import _security, _commit, _ctx from werkzeug.datastructures import MultiDict @@ -280,7 +280,10 @@ class BrowserModule(PgAdminModule): 'browser.check_master_password', 'browser.set_master_password', 'browser.reset_master_password', - 'browser.lock_layout'] + 'browser.lock_layout', + 'browser.kerberos_logout', + 'browser.kerberos_login', + ] blueprint = BrowserModule(MODULE_NAME, __name__) @@ -539,6 +542,11 @@ class BrowserPluginModule(PgAdminModule): def _get_logout_url(): + if session['_auth_source_manager_obj']['current_source'] == \ + current_app.PGADMIN_KERBEROS_AUTH_SOURCE: + return '{0}?next={1}'.format(url_for( + 'browser.kerberos_logout'), url_for(BROWSER_INDEX)) + return '{0}?next={1}'.format( url_for('security.logout'), url_for(BROWSER_INDEX)) @@ -623,6 +631,28 @@ def check_browser_upgrade(): flash(msg, 'warning') +@blueprint.route("/kerberos_login", + endpoint="kerberos_login", methods=["GET"]) +@pgCSRFProtect.exempt +def kerberos_login(): + logout_user() + return Response(render_template( + MODULE_NAME + "/kerberos_login.html", + login_url=url_for('security.login'), + )) + + +@blueprint.route("/kerberos_logout", + endpoint="kerberos_logout", methods=["GET"]) +@pgCSRFProtect.exempt +def kerberos_logout(): + logout_user() + return Response(render_template( + MODULE_NAME + "/kerberos_logout.html", + login_url=url_for('security.login'), + )) + + @blueprint.route("/") @pgCSRFProtect.exempt @login_required diff --git a/web/pgadmin/browser/templates/browser/kerberos_login.html b/web/pgadmin/browser/templates/browser/kerberos_login.html new file mode 100644 index 000000000..c112e3196 --- /dev/null +++ b/web/pgadmin/browser/templates/browser/kerberos_login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block body %} +
+
+
+
+
{{ _('%(appname)s', appname=config.APP_NAME) }}
+
+
{{ _('Login Failed.') }}
+
Click here to Login again.
+
+
+
+
+
+{% endblock %} diff --git a/web/pgadmin/browser/templates/browser/kerberos_logout.html b/web/pgadmin/browser/templates/browser/kerberos_logout.html new file mode 100644 index 000000000..430dc6f25 --- /dev/null +++ b/web/pgadmin/browser/templates/browser/kerberos_logout.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block body %} +
+
+
+
+
{{ _('%(appname)s', appname=config.APP_NAME) }}
+
+
{{ _('Logged out successfully.') }}
+
Click here to Login again.
+
+
+
+
+
+{% endblock %} diff --git a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py new file mode 100644 index 000000000..50caa71e9 --- /dev/null +++ b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py @@ -0,0 +1,106 @@ +########################################################################## +# +# 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 pgadmin.authenticate.registry import AuthSourceRegistry +from unittest.mock import patch, MagicMock + + +class KerberosLoginMockTestCase(BaseTestGenerator): + """ + This class checks Spnego/Kerberos login functionality by mocking + HTTP negotiate authentication. + """ + + scenarios = [ + ('Spnego/Kerberos Authentication: Test Unauthorized', dict( + auth_source=['kerberos'], + auto_create_user=True, + username='user@PGADMIN.ORG', + password='PASSWORD', + flag=1 + )), + ('Spnego/Kerberos Authentication: Test Authorized', dict( + auth_source=['kerberos'], + auto_create_user=True, + username='user@PGADMIN.ORG', + password='PASSWORD', + flag=2 + )) + ] + + @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 + + def runTest(self): + """This function checks spnego/kerberos login functionality.""" + if self.flag == 1: + self.test_unauthorized() + elif self.flag == 2: + if app_config.SERVER_MODE is False: + self.skipTest( + "Can not run Kerberos Authentication in the Desktop mode." + ) + + self.test_authorized() + + def test_unauthorized(self): + """ + Ensure that when client sends the first request, + the Negotiate request is sent. + """ + res = self.tester.login(self.username, self.password, True) + self.assertEqual(res.status_code, 401) + self.assertEqual(res.headers.get('www-authenticate'), 'Negotiate') + + def test_authorized(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. + """ + + class delCrads: + def __init__(self): + self.initiator_name = 'user@PGADMIN.ORG' + del_crads = delCrads() + + AuthSourceRegistry.registry['kerberos'].negotiate_start = MagicMock( + return_value=[True, del_crads]) + res = self.tester.login(self.username, + self.password, + True, + headers={'Authorization': 'Negotiate CTOKEN'} + ) + self.assertEqual(res.status_code, 200) + 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/utils/master_password.py b/web/pgadmin/utils/master_password.py index 759bf36e0..be655bef2 100644 --- a/web/pgadmin/utils/master_password.py +++ b/web/pgadmin/utils/master_password.py @@ -1,5 +1,5 @@ import config -from flask import current_app +from flask import current_app, session from flask_login import current_user from pgadmin.model import db, User, Server from pgadmin.utils.crypto import encrypt, decrypt @@ -32,6 +32,11 @@ def get_crypt_key(): elif config.MASTER_PASSWORD_REQUIRED \ and not config.SERVER_MODE and enc_key is None: return False, None + elif config.SERVER_MODE and \ + session['_auth_source_manager_obj']['source_friendly_name']\ + == current_app.PGADMIN_KERBEROS_AUTH_SOURCE: + return True, session['kerberos_key'] if 'kerberos_key' in session \ + else None else: return True, enc_key diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py index ad3396790..d1adfdc54 100644 --- a/web/regression/python_test_utils/csrf_test_client.py +++ b/web/regression/python_test_utils/csrf_test_client.py @@ -101,7 +101,8 @@ class TestClient(testing.FlaskClient): return csrf_token - def login(self, email, password, _follow_redirects=False): + def login(self, email, password, _follow_redirects=False, + headers=None): if config.SERVER_MODE is True: res = self.get('/login', follow_redirects=True) csrf_token = self.fetch_csrf(res) @@ -113,7 +114,8 @@ class TestClient(testing.FlaskClient): email=email, password=password, csrf_token=csrf_token, ), - follow_redirects=_follow_redirects + follow_redirects=_follow_redirects, + headers=headers ) self.csrf_token = csrf_token diff --git a/web/regression/runtests.py b/web/regression/runtests.py index a62358fac..a6782f36e 100644 --- a/web/regression/runtests.py +++ b/web/regression/runtests.py @@ -119,6 +119,8 @@ app.config['WTF_CSRF_ENABLED'] = True # Authentication sources app.PGADMIN_DEFAULT_AUTH_SOURCE = 'internal' app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap' +app.PGADMIN_KERBEROS_AUTH_SOURCE = 'kerberos' +app.PGADMIN_LDAP_AUTH_SOURCE = 'ldap' app.test_client_class = TestClient test_client = app.test_client() diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +