Hi, Please find the attached patch with some minor improvements.
Thanks, Khushboo On Wed, Apr 7, 2021 at 11:50 PM Khushboo Vashi < khushboo.va...@enterprisedb.com> wrote: > Hi, > > Please find the attached patch for RM 6158: Support Kerberos > Authentication - Phase 2. > This patch includes the support for logging into PostgreSQL servers with > Kerberos authentication. > > Thanks, > Khushboo > >
diff --git a/web/config.py b/web/config.py index 3e19a2858..e96d60c65 100644 --- a/web/config.py +++ b/web/config.py @@ -634,6 +634,9 @@ KRB_KTNAME = '<KRB5_KEYTAB_FILE>' KRB_AUTO_CREATE_USER = True +KERBEROS_CCACHE_DIR = os.path.join(DATA_DIR, 'krbccache') + + ########################################################################## # Local config settings ########################################################################## diff --git a/web/migrations/versions/cce2006fa107_.py b/web/migrations/versions/cce2006fa107_.py new file mode 100644 index 000000000..0cd3d7da1 --- /dev/null +++ b/web/migrations/versions/cce2006fa107_.py @@ -0,0 +1,28 @@ + +"""empty message + +Revision ID: cce2006fa107 +Revises: a39bd015b644 +Create Date: 2021-03-15 00:02:40.100252 + +""" +from alembic import op +import sqlalchemy as sa +from pgadmin.model import db + +# revision identifiers, used by Alembic. +revision = 'cce2006fa107' +down_revision = 'a39bd015b644' +branch_labels = None +depends_on = None + + +def upgrade(): + db.engine.execute( + 'ALTER TABLE server ADD COLUMN kerberos_conn INTEGER DEFAULT 0' + ) + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py index 9166c2ffd..3bc64483e 100644 --- a/web/pgadmin/authenticate/__init__.py +++ b/web/pgadmin/authenticate/__init__.py @@ -13,10 +13,13 @@ import flask import pickle from flask import current_app, flash, Response, request, url_for,\ render_template -from flask_security import current_user +from flask_babelex import gettext +from flask_security import current_user, login_required from flask_security.views import _security, _ctx from flask_security.utils import config_value, get_post_logout_redirect, \ get_post_login_redirect, logout_user +from pgadmin.utils.ajax import make_json_response, internal_server_error +import os from flask import session @@ -34,7 +37,9 @@ class AuthenticateModule(PgAdminModule): def get_exposed_url_endpoints(self): return ['authenticate.login', 'authenticate.kerberos_login', - 'authenticate.kerberos_logout'] + 'authenticate.kerberos_logout', + 'authenticate.kerberos_update_ticket', + 'authenticate.kerberos_validate_ticket'] blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='') @@ -55,6 +60,12 @@ def kerberos_login(): @pgCSRFProtect.exempt def kerberos_logout(): logout_user() + if 'KRB5CCNAME' in session: + # Remove the credential cache + cache_file_path = session['KRB5CCNAME'].split(":")[1] + if os.path.exists(cache_file_path): + os.remove(cache_file_path) + return Response(render_template("browser/kerberos_logout.html", login_url=url_for('security.login'), )) @@ -173,11 +184,13 @@ class AuthSourceManager(): # 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 and (hasattr(msg, 'status') and - msg.status == '401 UNAUTHORIZED') or \ - (source.get_source_name() == KERBEROS and - request.method == 'GET'): - break + if not status: + if (hasattr(msg, 'status') and + msg.status == '401 UNAUTHORIZED') or\ + (source.get_source_name() == + KERBEROS and + request.method == 'GET'): + break if status: self.set_source(source) @@ -224,3 +237,58 @@ def init_app(app): AuthSourceRegistry.load_auth_sources() return auth_sources + + +@blueprint.route("/kerberos/update_ticket", + endpoint="kerberos_update_ticket", methods=["GET"]) +@pgCSRFProtect.exempt +@login_required +def kerberos_update_ticket(): + """ + Update the kerberos ticket. + """ + from werkzeug.datastructures import Headers + headers = Headers() + + authorization = request.headers.get("Authorization", None) + + if authorization is None: + # Send the Negotiate header to the client + # if Kerberos ticket is not found. + headers.add('WWW-Authenticate', 'Negotiate') + return Response("Unauthorised", 401, headers) + else: + source = get_auth_sources(KERBEROS) + auth_header = authorization.split() + in_token = auth_header[1] + + # Validate the Kerberos ticket + status, context = source.negotiate_start(in_token) + if status: + return Response("Ticket updated successfully.") + + return Response(context, 500) + + +@blueprint.route("/kerberos/validate_ticket", + endpoint="kerberos_validate_ticket", methods=["GET"]) +@pgCSRFProtect.exempt +@login_required +def kerberos_validate_ticket(): + """ + Return the kerberos ticket lifetime left after getting the + ticket from the credential cache + """ + import gssapi + + try: + del_creds = gssapi.Credentials(store={'ccache': session['KRB5CCNAME']}) + creds = del_creds.acquire(store={'ccache': session['KRB5CCNAME']}) + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + return make_json_response( + data={'ticket_lifetime': creds.lifetime}, + status=200 + ) diff --git a/web/pgadmin/authenticate/kerberos.py b/web/pgadmin/authenticate/kerberos.py index 57aa1e0f0..2f8fd0d6e 100644 --- a/web/pgadmin/authenticate/kerberos.py +++ b/web/pgadmin/authenticate/kerberos.py @@ -10,7 +10,7 @@ """A blueprint module implementing the Spnego/Kerberos authentication.""" import base64 -from os import environ +from os import environ, path from werkzeug.datastructures import Headers from flask_babelex import gettext @@ -128,19 +128,37 @@ class KerberosAuthentication(BaseAuthentication): if out_token and not context.complete: return False, out_token if context.complete: + deleg_creds = context.delegated_creds + if not hasattr(deleg_creds, 'name'): + error_msg = gettext('Delegated credentials not supplied.') + current_app.logger.error(error_msg) + return False, Exception(error_msg) + try: + cache_file_path = path.join( + config.KERBEROS_CCACHE_DIR, 'pgadmin_cache_{0}'.format( + deleg_creds.name) + ) + CCACHE = 'FILE:{0}'.format(cache_file_path) + store = {'ccache': CCACHE} + deleg_creds.store(store, overwrite=True, set_default=True) + session['KRB5CCNAME'] = CCACHE + except Exception as e: + current_app.logger.exception(e) + return False, e + return True, context else: return False, None def negotiate_end(self, context): - # Free gss_cred_id_t + # Free Delegated Credentials 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.""" + """Add the kerberos user to the internal SQLite database.""" username = str(username) if config.KRB_AUTO_CREATE_USER: user = User.query.filter_by( diff --git a/web/pgadmin/authenticate/static/js/kerberos.js b/web/pgadmin/authenticate/static/js/kerberos.js new file mode 100644 index 000000000..dffe1d4dc --- /dev/null +++ b/web/pgadmin/authenticate/static/js/kerberos.js @@ -0,0 +1,55 @@ +import url_for from 'sources/url_for'; + +function fetch_ticket() { + // Fetch the Kerberos Updated ticket through SPNEGO + return fetch(url_for('authenticate.kerberos_update_ticket') + ) + .then(function(response){ + if (response.status >= 200 && response.status < 300) { + return Promise.resolve(response); + } else { + return Promise.reject(new Error(response.statusText)); + } + }); +} + +function fetch_ticket_lifetime () { + // Fetch the Kerberos ticket lifetime left + + return fetch(url_for('authenticate.kerberos_validate_ticket') + ) + .then( + function(response){ + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + return Promise.reject(new Error(response.statusText)); + } + } + ) + .then(function(response){ + let ticket_lifetime = response.data.ticket_lifetime; + if (ticket_lifetime > 0) { + return Promise.resolve(ticket_lifetime); + } else { + return Promise.reject(); + } + }); + +} + +function validate_kerberos_ticket() { + // Ping pgAdmin server every 10 seconds + // to fetch the Kerberos ticket lifetime left + return setInterval(function() { + let newPromise = fetch_ticket_lifetime(); + newPromise.then( + function() { + return; + }, + fetch_ticket + ); + }, 10000); +} + +export {fetch_ticket, validate_kerberos_ticket, fetch_ticket_lifetime}; diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index bc0bd4611..a7e5288e5 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -253,7 +253,8 @@ class ServerModule(sg.ServerGroupPluginModule): errmsg=errmsg, user_id=server.user_id, user_name=server.username, - shared=server.shared + shared=server.shared, + is_kerberos_conn=bool(server.kerberos_conn), ) @property @@ -546,7 +547,8 @@ class ServerNode(PGChildNodeView): if server.tunnel_password is not None else False, errmsg=errmsg, user_name=server.username, - shared=server.shared + shared=server.shared, + is_kerberos_conn=bool(server.kerberos_conn) ) ) @@ -613,7 +615,8 @@ class ServerNode(PGChildNodeView): if server.tunnel_password is not None else False, errmsg=errmsg, shared=server.shared, - user_name=server.username + user_name=server.username, + is_kerberos_conn=bool(server.kerberos_conn) ), ) @@ -719,7 +722,8 @@ class ServerNode(PGChildNodeView): 'tunnel_username': 'tunnel_username', 'tunnel_authentication': 'tunnel_authentication', 'tunnel_identity_file': 'tunnel_identity_file', - 'shared': 'shared' + 'shared': 'shared', + 'kerberos_conn': 'kerberos_conn', } disp_lbl = { @@ -983,7 +987,8 @@ class ServerNode(PGChildNodeView): 'tunnel_username': tunnel_username, 'tunnel_identity_file': server.tunnel_identity_file if server.tunnel_identity_file else None, - 'tunnel_authentication': tunnel_authentication + 'tunnel_authentication': tunnel_authentication, + 'kerberos_conn': bool(server.kerberos_conn), } return ajax_response(response) @@ -1070,7 +1075,8 @@ class ServerNode(PGChildNodeView): tunnel_authentication=data.get('tunnel_authentication', 0), tunnel_identity_file=data.get('tunnel_identity_file', None), shared=data.get('shared', None), - passfile=data.get('passfile', None) + passfile=data.get('passfile', None), + kerberos_conn=1 if data.get('kerberos_conn', False) else 0, ) db.session.add(server) db.session.commit() @@ -1152,7 +1158,8 @@ class ServerNode(PGChildNodeView): else 'pg', version=manager.version if manager and manager.version - else None + else None, + is_kerberos_conn=bool(server.kerberos_conn), ) ) @@ -1346,7 +1353,7 @@ class ServerNode(PGChildNodeView): except Exception as e: current_app.logger.exception(e) return internal_server_error(errormsg=str(e)) - if 'password' not in data: + if 'password' not in data and server.kerberos_conn is False: conn_passwd = getattr(conn, 'password', None) if conn_passwd is None and not server.save_password and \ server.passfile is None and server.service is None: @@ -1355,7 +1362,7 @@ class ServerNode(PGChildNodeView): passfile = server.passfile else: password = conn_passwd or server.password - else: + elif server.kerberos_conn is False: password = data['password'] if 'password' in data else None save_password = data['save_password']\ if 'save_password' in data else False @@ -1398,6 +1405,9 @@ class ServerNode(PGChildNodeView): "Could not connect to server(#{0}) - '{1}'.\nError: {2}" .format(server.id, server.name, errmsg) ) + if errmsg.find('Ticket expired') != -1: + return internal_server_error(errmsg) + return self.get_response_for_password(server, 401, True, True, errmsg) else: @@ -1465,6 +1475,7 @@ class ServerNode(PGChildNodeView): 'is_password_saved': bool(server.save_password), 'is_tunnel_password_saved': True if server.tunnel_password is not None else False, + 'is_kerberos_conn': bool(server.kerberos_conn), } ) diff --git a/web/pgadmin/browser/server_groups/servers/databases/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/__init__.py index 60af1de42..4b1d7308d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/__init__.py @@ -490,6 +490,7 @@ class DatabaseView(PGChildNodeView): did, errmsg ) ) + return internal_server_error(errmsg) else: current_app.logger.info( diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js index c53f04429..01ab89c50 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js @@ -10,9 +10,10 @@ define('pgadmin.node.database', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'sources/utils', 'sources/pgadmin', 'pgadmin.browser.utils', - 'pgadmin.alertifyjs', 'pgadmin.backform', 'pgadmin.browser.collection', + 'pgadmin.alertifyjs', 'pgadmin.backform', + 'pgadmin.authenticate.kerberos', 'pgadmin.browser.collection', 'pgadmin.browser.server.privilege', 'pgadmin.browser.server.variable', -], function(gettext, url_for, $, _, pgadminUtils, pgAdmin, pgBrowser, Alertify, Backform) { +], function(gettext, url_for, $, _, pgadminUtils, pgAdmin, pgBrowser, Alertify, Backform, Kerberos) { if (!pgBrowser.Nodes['coll-database']) { pgBrowser.Nodes['coll-database'] = @@ -556,24 +557,39 @@ define('pgadmin.node.database', [ onFailure = function( xhr, status, error, _model, _data, _tree, _item, _status ) { - if (!_status) { - tree.setInode(_item); - tree.addIcon(_item, {icon: 'icon-database-not-connected'}); - } - - Alertify.pgNotifier('error', xhr, error, function(msg) { - setTimeout(function() { - if (msg == 'CRYPTKEY_SET') { + if (xhr.status != 200 && xhr.responseText.search('Ticket expired') !== -1) { + tree.addIcon(_item, {icon: 'icon-server-connecting'}); + let fetchTicket = Kerberos.fetch_ticket(); + fetchTicket.then( + function() { connect_to_database(_model, _data, _tree, _item, _wasConnected); - } else { - Alertify.dlgServerPass( - gettext('Connect to database'), - msg, _model, _data, _tree, _item, _status, - onSuccess, onFailure, onCancel - ).resizeTo(); + }, + function(error) { + tree.setInode(_item); + tree.addIcon(_item, {icon: 'icon-database-not-connected'}); + Alertify.pgNotifier(error, xhr, gettext('Connect to database.')); } - }, 100); - }); + ); + } else { + if (!_status) { + tree.setInode(_item); + tree.addIcon(_item, {icon: 'icon-database-not-connected'}); + } + + Alertify.pgNotifier('error', xhr, error, function(msg) { + setTimeout(function() { + if (msg == 'CRYPTKEY_SET') { + connect_to_database(_model, _data, _tree, _item, _wasConnected); + } else { + Alertify.dlgServerPass( + gettext('Connect to database'), + msg, _model, _data, _tree, _item, _status, + onSuccess, onFailure, onCancel + ).resizeTo(); + } + }, 100); + }); + } }, onSuccess = function( res, model, _data, _tree, _item, _connected @@ -640,6 +656,7 @@ define('pgadmin.node.database', [ if (xhr.status === 410) { error = gettext('Error: Object not found - %s.', error); } + return onFailure( xhr, status, error, obj, data, tree, item, wasConnected ); diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index ab95d6d89..deee434bd 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -13,11 +13,12 @@ define('pgadmin.node.server', [ 'pgadmin.server.supported_servers', 'pgadmin.user_management.current_user', 'pgadmin.alertifyjs', 'pgadmin.backform', 'sources/browser/server_groups/servers/model_validation', + 'pgadmin.authenticate.kerberos', 'pgadmin.browser.server.privilege', ], function( gettext, url_for, $, _, Backbone, pgAdmin, pgBrowser, supported_servers, current_user, Alertify, Backform, - modelValidation + modelValidation, Kerberos ) { if (!pgBrowser.Nodes['server']) { @@ -904,20 +905,32 @@ define('pgadmin.node.server', [ } }, }), + },{ + id: 'kerberos_conn', label: gettext('Kerberos authentication?'), type: 'switch', + group: gettext('Connection'), 'options': { + 'onText': gettext('True'), 'offText': gettext('False'), 'size': 'mini', + }, },{ id: 'password', label: gettext('Password'), type: 'password', maxlength: null, - group: gettext('Connection'), control: 'input', mode: ['create'], deps: ['connect_now'], + group: gettext('Connection'), control: 'input', mode: ['create'], + deps: ['connect_now', 'kerberos_conn'], visible: function(model) { return model.get('connect_now') && model.isNew(); }, + disabled: function(model) { + if (model.get('kerberos_conn')) + return true; + + return false; + }, },{ id: 'save_password', controlLabel: gettext('Save password?'), type: 'checkbox', group: gettext('Connection'), mode: ['create'], - deps: ['connect_now'], visible: function(model) { + deps: ['connect_now', 'kerberos_conn'], visible: function(model) { return model.get('connect_now') && model.isNew(); }, - disabled: function() { - if (!current_user.allow_save_password) + disabled: function(model) { + if (!current_user.allow_save_password || model.get('kerberos_conn')) return true; return false; @@ -1279,19 +1292,32 @@ define('pgadmin.node.server', [ } } - - Alertify.pgNotifier('error', xhr, error, function(msg) { - setTimeout(function() { - if (msg == 'CRYPTKEY_SET') { + if (_data.is_kerberos_conn === true || (xhr.status != 200 && xhr.responseText.search('Ticket expired') !== -1)) { + tree.addIcon(_item, {icon: 'icon-server-connecting'}); + let fetchTicket = Kerberos.fetch_ticket(); + fetchTicket.then( + function() { connect_to_server(_node, _data, _tree, _item, _wasConnected); - } else { - Alertify.dlgServerPass( - gettext('Connect to Server'), - msg, _node, _data, _tree, _item, _wasConnected - ).resizeTo(); + }, + function() { + tree.addIcon(_item, {icon: 'icon-server-not-connected'}); + Alertify.pgNotifier('Connection error', xhr, gettext('Connect to server.')); } - }, 100); - }); + ); + } else { + Alertify.pgNotifier('error', xhr, error, function(msg) { + setTimeout(function() { + if (msg == 'CRYPTKEY_SET') { + connect_to_server(_node, _data, _tree, _item, _wasConnected); + } else { + Alertify.dlgServerPass( + gettext('Connect to Server'), + msg, _node, _data, _tree, _item, _wasConnected + ).resizeTo(); + } + }, 100); + }); + } }, onSuccess = function(res, node, _data, _tree, _item, _wasConnected) { if (res && res.data) { diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 4ffb5ee5a..bf44aa6f4 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -12,19 +12,22 @@ define('pgadmin.browser', [ 'sources/gettext', 'sources/url_for', 'require', 'jquery', 'underscore', 'bootstrap', 'sources/pgadmin', 'pgadmin.alertifyjs', 'bundled_codemirror', 'sources/check_node_visibility', './toolbar', 'pgadmin.help', - 'sources/csrf', 'sources/utils', 'sources/window', 'pgadmin.browser.utils', - 'wcdocker', 'jquery.contextmenu', 'jquery.aciplugin', 'jquery.acitree', + 'sources/csrf', 'sources/utils', 'sources/window', 'pgadmin.authenticate.kerberos', + 'pgadmin.browser.utils', 'wcdocker', 'jquery.contextmenu', 'jquery.aciplugin', + 'jquery.acitree', 'pgadmin.browser.preferences', 'pgadmin.browser.messages', 'pgadmin.browser.menu', 'pgadmin.browser.panel', 'pgadmin.browser.layout', 'pgadmin.browser.runtime', 'pgadmin.browser.error', 'pgadmin.browser.frame', 'pgadmin.browser.node', 'pgadmin.browser.collection', 'pgadmin.browser.activity', 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', - 'pgadmin.browser.keyboard', 'sources/tree/pgadmin_tree_save_state','jquery.acisortable', 'jquery.acifragment', + 'pgadmin.browser.keyboard', 'sources/tree/pgadmin_tree_save_state','jquery.acisortable', + 'jquery.acifragment', ], function( tree, gettext, url_for, require, $, _, Bootstrap, pgAdmin, Alertify, codemirror, - checkNodeVisibility, toolBar, help, csrfToken, pgadminUtils, pgWindow + checkNodeVisibility, toolBar, help, csrfToken, pgadminUtils, pgWindow, + Kerberos ) { window.jQuery = window.$ = $; // Some scripts do export their object in the window only. @@ -38,6 +41,8 @@ define('pgadmin.browser', [ csrfToken.setPGCSRFToken(pgAdmin.csrf_token_header, pgAdmin.csrf_token); + Kerberos.validate_kerberos_ticket(); + var panelEvents = {}; panelEvents[wcDocker.EVENT.VISIBILITY_CHANGED] = function() { if (this.isVisible()) { diff --git a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py index f31e983ff..6b61dc1d0 100644 --- a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py +++ b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py @@ -12,6 +12,7 @@ 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 werkzeug.datastructures import Headers class KerberosLoginMockTestCase(BaseTestGenerator): @@ -30,6 +31,11 @@ class KerberosLoginMockTestCase(BaseTestGenerator): auth_source=['kerberos'], auto_create_user=True, flag=2 + )), + ('Spnego/Kerberos Update Ticket', dict( + auth_source=['kerberos'], + auto_create_user=True, + flag=3 )) ] @@ -54,8 +60,13 @@ class KerberosLoginMockTestCase(BaseTestGenerator): self.skipTest( "Can not run Kerberos Authentication in the Desktop mode." ) - self.test_authorized() + elif self.flag == 3: + if app_config.SERVER_MODE is False: + self.skipTest( + "Can not run Kerberos Authentication in the Desktop mode." + ) + self.test_update_ticket() def test_unauthorized(self): """ @@ -73,13 +84,7 @@ class KerberosLoginMockTestCase(BaseTestGenerator): passed on to the routed method. """ - class delCrads: - def __init__(self): - self.initiator_name = 'u...@pgadmin.org' - del_crads = delCrads() - - AuthSourceRegistry.registry['kerberos'].negotiate_start = MagicMock( - return_value=[True, del_crads]) + del_crads = self.mock_negotiate_start() res = self.tester.login(None, None, True, @@ -89,6 +94,33 @@ class KerberosLoginMockTestCase(BaseTestGenerator): respdata = 'Gravatar image for %s' % del_crads.initiator_name self.assertTrue(respdata in res.data.decode('utf8')) + def mock_negotiate_start(self): + class delCrads: + def __init__(self): + self.initiator_name = 'u...@pgadmin.org' + + del_crads = delCrads() + + AuthSourceRegistry.registry['kerberos'].negotiate_start = MagicMock( + return_value=[True, del_crads]) + return del_crads + + def test_update_ticket(self): + # Response header should include the Negotiate header in the first call + response = self.tester.get('/authenticate/kerberos/update_ticket') + self.assertEqual(response.status_code, 401) + self.assertEqual(response.headers.get('www-authenticate'), 'Negotiate') + + # When we send the Kerberos Ticket, it should return success + del_crads = self.mock_negotiate_start() + + krb_token = Headers({}) + krb_token['Authorization'] = 'Negotiate CTOKEN' + + response = self.tester.get('/authenticate/kerberos/update_ticket', + headers=krb_token) + self.assertEqual(response.status_code, 200) + def tearDown(self): self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap' diff --git a/web/pgadmin/misc/bgprocess/processes.py b/web/pgadmin/misc/bgprocess/processes.py index ef6cfc3f2..25e0a2a9e 100644 --- a/web/pgadmin/misc/bgprocess/processes.py +++ b/web/pgadmin/misc/bgprocess/processes.py @@ -24,10 +24,11 @@ import logging from pgadmin.utils import u_encode, file_quote, fs_encoding, \ get_complete_file_path, get_storage_directory, IS_WIN from pgadmin.browser.server_groups.servers.utils import does_server_exists +from pgadmin.utils.constants import KERBEROS import pytz from dateutil import parser -from flask import current_app +from flask import current_app, session from flask_babelex import gettext as _ from flask_security import current_user @@ -278,13 +279,16 @@ class BatchProcess(object): env['PROCID'] = self.id env['OUTDIR'] = self.log_dir env['PGA_BGP_FOREGROUND'] = "1" + if config.SERVER_MODE and session and \ + session['_auth_source_manager_obj']['current_source'] == \ + KERBEROS: + env['KRB5CCNAME'] = session['KRB5CCNAME'] if self.env: env.update(self.env) if cb is not None: cb(env) - if os.name == 'nt': DETACHED_PROCESS = 0x00000008 from subprocess import CREATE_NEW_PROCESS_GROUP diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index d1f498181..dfacf6322 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy # ########################################################################## -SCHEMA_VERSION = 27 +SCHEMA_VERSION = 28 ########################################################################## # @@ -184,6 +184,7 @@ class Server(db.Model): tunnel_identity_file = db.Column(db.String(64), nullable=True) tunnel_password = db.Column(db.String(64), nullable=True) shared = db.Column(db.Boolean(), nullable=False) + kerberos_conn = db.Column(db.Boolean(), nullable=False) @property def serialize(self): diff --git a/web/pgadmin/setup/data_directory.py b/web/pgadmin/setup/data_directory.py index 2335b0790..7a3654b77 100644 --- a/web/pgadmin/setup/data_directory.py +++ b/web/pgadmin/setup/data_directory.py @@ -104,3 +104,19 @@ def create_app_data_directory(config): getpass.getuser(), config.APP_VERSION)) exit(1) + + # Create Kerberos Credential Cache directory (if not present). + try: + _create_directory_if_not_exists(config.KERBEROS_CCACHE_DIR) + except PermissionError as e: + print(FAILED_CREATE_DIR.format(config.KERBEROS_CCACHE_DIR, e)) + print( + "HINT : Create the directory {}, ensure it is writable by\n" + " '{}', and try again, or, create a config_local.py file\n" + " and override the KERBEROS_CCACHE_DIR setting per\n" + " https://www.pgadmin.org/docs/pgadmin4/{}/config_py.html". + format( + config.KERBEROS_CCACHE_DIR, + getpass.getuser(), + config.APP_VERSION)) + exit(1) diff --git a/web/pgadmin/tools/backup/static/js/backup_dialog_wrapper.js b/web/pgadmin/tools/backup/static/js/backup_dialog_wrapper.js index 4f89e5bb7..5e4db20a9 100644 --- a/web/pgadmin/tools/backup/static/js/backup_dialog_wrapper.js +++ b/web/pgadmin/tools/backup/static/js/backup_dialog_wrapper.js @@ -13,6 +13,7 @@ import gettext from '../../../../static/js/gettext'; import url_for from '../../../../static/js/url_for'; import _ from 'underscore'; import {DialogWrapper} from '../../../../static/js/alertify/dialog_wrapper'; +import {fetch_ticket_lifetime} from '../../../../authenticate/static/js/kerberos'; export class BackupDialogWrapper extends DialogWrapper { constructor(dialogContainerSelector, dialogTitle, typeOfDialog, @@ -165,10 +166,29 @@ export class BackupDialogWrapper extends DialogWrapper { ); this.setExtraParameters(selectedTreeNode, treeInfo); + let backupDate = this.view.model.toJSON(); + + if(backupDate.type == 'globals' || backupDate.type == 'server') { + let newPromise = fetch_ticket_lifetime(); + newPromise.then( + function(lifetime) { + if (lifetime < 1800 && lifetime > 0) { + dialog.alertify.warning( + 'You have '+ (Math.round(parseInt(lifetime)/60)).toString() +' minutes left on your ticket - if the dump takes longer than that, it may fail."' + ); + } + }, + function() { + dialog.alertify.warning( + gettext('Please renew your kerberos ticket, it has been expired.') + ); + } + ); + } axios.post( baseUrl, - this.view.model.toJSON() + backupDate ).then(function (res) { if (res.data.success) { dialog.alertify.success(gettext('Backup job created.'), 5); diff --git a/web/pgadmin/tools/debugger/static/js/debugger.js b/web/pgadmin/tools/debugger/static/js/debugger.js index f31a0fc00..460a200bb 100644 --- a/web/pgadmin/tools/debugger/static/js/debugger.js +++ b/web/pgadmin/tools/debugger/static/js/debugger.js @@ -13,11 +13,11 @@ define([ 'backbone', 'pgadmin.backgrid', 'codemirror', 'pgadmin.backform', 'pgadmin.tools.debugger.ui', 'pgadmin.tools.debugger.utils', 'tools/datagrid/static/js/show_query_tool', 'sources/utils', - 'wcdocker', 'pgadmin.browser.frame', + 'pgadmin.authenticate.kerberos', 'wcdocker', 'pgadmin.browser.frame', ], function( gettext, url_for, $, _, Alertify, pgAdmin, pgBrowser, Backbone, Backgrid, CodeMirror, Backform, get_function_arguments, debuggerUtils, showQueryTool, - pgadminUtils, + pgadminUtils, Kerberos ) { var pgTools = pgAdmin.Tools = pgAdmin.Tools || {}, wcDocker = window.wcDocker; @@ -472,8 +472,20 @@ define([ .fail(function(xhr) { try { var err = JSON.parse(xhr.responseText); - if (err.success == 0) { - Alertify.alert(gettext('Debugger Error'), err.errormsg); + if (err.errormsg.search('Ticket expired') !== -1) { + let fetchTicket = Kerberos.fetch_ticket(); + fetchTicket.then( + function() { + self.start_global_debugger(); + }, + function(error) { + Alertify.alert(gettext('Debugger Error'), error); + } + ); + } else { + if (err.success == 0) { + Alertify.alert(gettext('Debugger Error'), err.errormsg); + } } } catch (e) { console.warn(e.stack || e); diff --git a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js index b5503255c..631e9d0da 100644 --- a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js +++ b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js @@ -51,6 +51,7 @@ define('tools.querytool', [ 'sources/window', 'sources/is_native', 'sources/sqleditor/macro', + 'pgadmin.authenticate.kerberos', 'sources/../bundle/slickgrid', 'pgadmin.file_manager', 'slick.pgadmin.formatters', @@ -65,7 +66,7 @@ define('tools.querytool', [ GeometryViewer, historyColl, queryHist, querySources, keyboardShortcuts, queryToolActions, queryToolNotifications, Datagrid, modifyAnimation, calculateQueryRunTime, callRenderAfterPoll, queryToolPref, queryTxnStatus, csrfToken, panelTitleFunc, - pgWindow, isNative, MacroHandler) { + pgWindow, isNative, MacroHandler, Kerberos) { /* Return back, this has been called more than once */ if (pgAdmin.SqlEditor) return pgAdmin.SqlEditor; @@ -2441,9 +2442,23 @@ define('tools.querytool', [ pgBrowser.report_error(gettext('Error fetching rows - %s.', xhr.statusText), xhr.responseJSON.errormsg, undefined, self.close.bind(self)); } } else { - pgBrowser.Events.trigger( - 'pgadmin:query_tool:connected_fail:' + self.transId, xhr, error - ); + if (xhr.responseText.search('Ticket expired') !== -1) { + let fetchTicket = Kerberos.fetch_ticket(); + fetchTicket.then( + function() { + self.initTransaction(); + }, + function(error) { + pgBrowser.Events.trigger( + 'pgadmin:query_tool:connected_fail:' + self.transId, xhr, error + ); + } + ); + } else { + pgBrowser.Events.trigger( + 'pgadmin:query_tool:connected_fail:' + self.transId, xhr, error + ); + } } }); }, diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py index de9547322..43f6e6de0 100644 --- a/web/pgadmin/utils/driver/psycopg2/connection.py +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -18,11 +18,13 @@ import select import datetime from collections import deque import psycopg2 -from flask import g, current_app +import threading +from flask import g, current_app, session from flask_babelex import gettext from flask_security import current_user from pgadmin.utils.crypto import decrypt, encrypt from psycopg2.extensions import encodings +from os import environ import config from pgadmin.model import User @@ -38,6 +40,9 @@ from .encoding import get_encoding, configure_driver_encodings from pgadmin.utils import csv from pgadmin.utils.master_password import get_crypt_key from io import StringIO +from pgadmin.utils.constants import KERBEROS + +lock = threading.Lock() _ = gettext @@ -313,6 +318,12 @@ class Connection(BaseConnection): os.environ['PGAPPNAME'] = '{0} - {1}'.format( config.APP_NAME, conn_id) + if config.SERVER_MODE and \ + session['_auth_source_manager_obj']['current_source'] == \ + KERBEROS and 'KRB5CCNAME' in session: + lock.acquire() + environ['KRB5CCNAME'] = session['KRB5CCNAME'] + pg_conn = psycopg2.connect( host=manager.local_bind_host if manager.use_ssh_tunnel else manager.host, @@ -340,7 +351,13 @@ class Connection(BaseConnection): if self.async_ == 1: self._wait(pg_conn) + if config.SERVER_MODE and \ + session['_auth_source_manager_obj']['current_source'] == \ + KERBEROS: + environ['KRB5CCNAME'] = '' + except psycopg2.Error as e: + environ['KRB5CCNAME'] = '' manager.stop_ssh_tunnel() if e.pgerror: msg = e.pgerror @@ -358,6 +375,11 @@ class Connection(BaseConnection): ) ) return False, msg + finally: + if config.SERVER_MODE and \ + session['_auth_source_manager_obj']['current_source'] == \ + KERBEROS and lock.locked(): + lock.release() # Overwrite connection notice attr to support # more than 50 notices at a time @@ -1435,7 +1457,6 @@ Failed to reset the connection to the server due to following error: Args: conn: connection object """ - while True: state = conn.poll() if state == psycopg2.extensions.POLL_OK: diff --git a/web/webpack.shim.js b/web/webpack.shim.js index 96d5b27f6..00daa12ec 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -174,6 +174,7 @@ var webpackShimConfig = { 'pgadmin.backgrid': path.join(__dirname, './pgadmin/static/js/backgrid.pgadmin'), 'pgadmin.about': path.join(__dirname, './pgadmin/about/static/js/about'), + 'pgadmin.authenticate.kerberos': path.join(__dirname, './pgadmin/authenticate/static/js/kerberos'), 'pgadmin.browser': path.join(__dirname, './pgadmin/browser/static/js/browser'), 'pgadmin.browser.bgprocess': path.join(__dirname, './pgadmin/misc/bgprocess/static/js/bgprocess'), 'pgadmin.browser.collection': path.join(__dirname, './pgadmin/browser/static/js/collection'),