From 1257ec996902941b164b591947e38e8072ac7b89 Mon Sep 17 00:00:00 2001 From: Yogesh Mahajan Date: Thu, 22 Aug 2024 16:44:57 +0530 Subject: [PATCH] Revamp the current password saving implementation to keyring and reducing repeated OS user password prompts. #7076 The new implementation will store the master password in the keyring instead of storing each and every server password separately. The master password will be used to encrypt/decrypt server password when storing in the pgAdmin config DB. --- web/config.py | 2 +- web/pgadmin/authenticate/kerberos.py | 5 +- web/pgadmin/authenticate/oauth2.py | 4 +- web/pgadmin/authenticate/webserver.py | 6 +- web/pgadmin/browser/__init__.py | 276 +++++++--------- .../browser/server_groups/servers/__init__.py | 306 +++--------------- .../browser/server_groups/servers/utils.py | 239 +++++++++++++- .../browser/tests/test_master_password.py | 1 + web/pgadmin/evaluate_config.py | 8 +- .../js/Dialogs/MasterPasswordContent.jsx | 2 +- web/pgadmin/static/js/Dialogs/index.jsx | 145 +++++---- web/pgadmin/utils/constants.py | 1 + .../utils/driver/psycopg3/connection.py | 43 +-- .../utils/driver/psycopg3/server_manager.py | 34 +- web/pgadmin/utils/master_password.py | 100 ++++-- web/regression/runtests.py | 1 + 16 files changed, 610 insertions(+), 563 deletions(-) diff --git a/web/config.py b/web/config.py index d056ce98b..b4c25c079 100644 --- a/web/config.py +++ b/web/config.py @@ -583,7 +583,7 @@ ALLOW_SAVE_TUNNEL_PASSWORD = False # Applicable for desktop mode only ########################################################################## MASTER_PASSWORD_REQUIRED = True - +USE_OS_SECRET_STORAGE = True ########################################################################## # pgAdmin encrypts the database connection and ssh tunnel password using a diff --git a/web/pgadmin/authenticate/kerberos.py b/web/pgadmin/authenticate/kerberos.py index c0b22e072..3f8db6e67 100644 --- a/web/pgadmin/authenticate/kerberos.py +++ b/web/pgadmin/authenticate/kerberos.py @@ -30,7 +30,7 @@ from pgadmin.utils.ajax import make_json_response, internal_server_error from pgadmin.authenticate.internal import BaseAuthentication from pgadmin.authenticate import get_auth_sources from pgadmin.utils.csrf import pgCSRFProtect - +from pgadmin.utils.master_password import set_crypt_key try: import gssapi @@ -193,7 +193,8 @@ class KerberosAuthentication(BaseAuthentication): if status: # Saving the first 15 characters of the kerberos key # to encrypt/decrypt database password - session['pass_enc_key'] = auth_header[1][0:15] + pass_enc_key = auth_header[1][0:15] + set_crypt_key(pass_enc_key) # Create user retval = self.__auto_create_user( str(negotiate.initiator_name)) diff --git a/web/pgadmin/authenticate/oauth2.py b/web/pgadmin/authenticate/oauth2.py index b7642bb40..d1ce51a0b 100644 --- a/web/pgadmin/authenticate/oauth2.py +++ b/web/pgadmin/authenticate/oauth2.py @@ -26,6 +26,7 @@ from pgadmin.utils import PgAdminModule, get_safe_post_login_redirect, \ get_safe_post_logout_redirect from pgadmin.utils.csrf import pgCSRFProtect from pgadmin.model import db +from pgadmin.utils.master_password import set_crypt_key OAUTH2_LOGOUT = 'oauth2.logout' OAUTH2_AUTHORIZE = 'oauth2.authorize' @@ -210,7 +211,8 @@ class OAuth2Authentication(BaseAuthentication): session['oauth2_token'] = self.oauth2_clients[ self.oauth2_current_client].authorize_access_token() - session['pass_enc_key'] = session['oauth2_token']['access_token'] + pass_enc_key = session['oauth2_token']['access_token'] + set_crypt_key(pass_enc_key) if 'OAUTH2_LOGOUT_URL' in self.oauth2_config[ self.oauth2_current_client]: diff --git a/web/pgadmin/authenticate/webserver.py b/web/pgadmin/authenticate/webserver.py index 5a9e4533c..2c9f47e8d 100644 --- a/web/pgadmin/authenticate/webserver.py +++ b/web/pgadmin/authenticate/webserver.py @@ -12,7 +12,7 @@ import secrets import string import config -from flask import request, current_app, session, Response, render_template, \ +from flask import request, current_app, Response, render_template, \ url_for from flask_babel import gettext from flask_security import login_user @@ -23,6 +23,7 @@ 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 pgadmin.utils.master_password import set_crypt_key class WebserverModule(PgAdminModule): @@ -89,8 +90,9 @@ class WebserverAuthentication(BaseAuthentication): return False, gettext( "Webserver authenticate failed.") - session['pass_enc_key'] = ''.join( + pass_enc_key = ''.join( (secrets.choice(string.ascii_lowercase) for _ in range(10))) + set_crypt_key(pass_enc_key) useremail = request.environ.get('mail') if not useremail: useremail = '' diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 1867d6e86..1e4200177 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -10,22 +10,21 @@ import json import logging import os +import secrets import sys from abc import ABCMeta, abstractmethod from smtplib import SMTPConnectError, SMTPResponseException, \ SMTPServerDisconnected, SMTPDataError, SMTPHeloError, SMTPException, \ SMTPAuthenticationError, SMTPSenderRefused, SMTPRecipientsRefused from socket import error as SOCKETErrorException -from urllib.request import urlopen -from pgadmin.utils.constants import KEY_RING_SERVICE_NAME, \ - KEY_RING_USERNAME_FORMAT, KEY_RING_DESKTOP_USER, KEY_RING_TUNNEL_FORMAT, \ - MessageType - -import time import keyring +from keyring.errors import KeyringLocked +from pgadmin.utils.constants import KEY_RING_SERVICE_NAME, \ + KEY_RING_USER_NAME,MessageType + from flask import current_app, render_template, url_for, make_response, \ - flash, Response, request, after_this_request, redirect, session + flash, Response, request, redirect, session from flask_babel import gettext from libgravatar import Gravatar from flask_security import current_user @@ -44,22 +43,24 @@ from werkzeug.datastructures import MultiDict import config from pgadmin import current_blueprint from pgadmin.authenticate import get_logout_url -from pgadmin.authenticate.mfa.utils import mfa_required, is_mfa_enabled -from pgadmin.settings import get_setting, store_setting +from pgadmin.authenticate.mfa.utils import is_mfa_enabled +from pgadmin.settings import get_setting from pgadmin.utils import PgAdminModule from pgadmin.utils.ajax import make_json_response, internal_server_error, \ bad_request from pgadmin.utils.csrf import pgCSRFProtect from pgadmin.utils.preferences import Preferences -from pgadmin.utils.menu import MenuItem from pgadmin.browser.register_browser_preferences import \ register_browser_preferences 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 + set_crypt_key, process_masterpass_disabled, \ + delete_local_storage_master_key, \ + get_master_password_key_from_os_secret, \ + get_master_password_from_master_hook from pgadmin.model import User, db -from pgadmin.utils.constants import MIMETYPE_APP_JS, PGADMIN_NODE,\ - INTERNAL, KERBEROS, LDAP, QT_DEFAULT_PLACEHOLDER, OAUTH2, WEBSERVER,\ +from pgadmin.utils.constants import MIMETYPE_APP_JS, PGADMIN_NODE, \ + INTERNAL, KERBEROS, LDAP, QT_DEFAULT_PLACEHOLDER, OAUTH2, WEBSERVER, \ VW_EDT_DEFAULT_PLACEHOLDER from pgadmin.authenticate import AuthSourceManager from pgadmin.utils.exception import CryptKeyMissing @@ -74,7 +75,7 @@ PGADMIN_BROWSER = 'pgAdmin.Browser' PASS_ERROR_MSG = gettext('Your password has not been changed.') SMTP_SOCKET_ERROR = gettext( 'SMTP Socket error: {error}\n {pass_error}').format( - error={}, pass_error=PASS_ERROR_MSG) + error={}, pass_error=PASS_ERROR_MSG) SMTP_ERROR = gettext('SMTP error: {error}\n {pass_error}').format( error={}, pass_error=PASS_ERROR_MSG) PASS_ERROR = gettext('Error: {error}\n {pass_error}').format( @@ -631,14 +632,13 @@ def get_nodes(): def form_master_password_response(existing=True, present=False, errmsg=None, - keyring_name='', - invalid_master_password_hook=False): + keyring_name='', master_password_hook=''): return make_json_response(data={ 'present': present, 'reset': existing, 'errmsg': errmsg, 'keyring_name': keyring_name, - 'invalid_master_password_hook': invalid_master_password_hook, + 'master_password_hook': master_password_hook, 'is_error': True if errmsg else False }) @@ -673,14 +673,11 @@ def reset_master_password(): Removes the master password and remove all saved passwords This password will be used to encrypt/decrypt saved server passwords """ - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - # This is to set the Desktop user password so it will not ask for - # migrate exiting passwords as those are getting cleared - keyring.set_password(KEY_RING_SERVICE_NAME, - KEY_RING_DESKTOP_USER.format( - current_user.username), 'test') cleanup_master_password() status, crypt_key = get_crypt_key() + if not status and config.MASTER_PASSWORD_HOOK: + crypt_key = get_master_password_from_master_hook() + # Set masterpass_check if MASTER_PASSWORD_HOOK is set which provides # encryption key if config.MASTER_PASSWORD_REQUIRED and config.MASTER_PASSWORD_HOOK: @@ -695,9 +692,7 @@ def set_master_password(): Set the master password and store in the memory This password will be used to encrypt/decrypt saved server passwords """ - data = None - if request.form: data = request.form elif request.data: @@ -708,130 +703,105 @@ def set_master_password(): if data != '': data = json.loads(data) - if not config.DISABLED_LOCAL_PASSWORD_STORAGE and \ - (config.ALLOW_SAVE_PASSWORD or config.ALLOW_SAVE_TUNNEL_PASSWORD): - if data.get('password') and config.MASTER_PASSWORD_REQUIRED and\ - not validate_master_password(data.get('password')): - return form_master_password_response( - present=False, - keyring_name=config.KEYRING_NAME, - errmsg=gettext("Incorrect master password") - ) - from pgadmin.model import Server - from pgadmin.utils.crypto import decrypt - desktop_user = current_user + keyring_name = '' + errmsg = '' + if not config.SERVER_MODE: + if config.USE_OS_SECRET_STORAGE: + try: + # Try to get master key is from local os storage + master_key = get_master_password_key_from_os_secret() + master_password = data.get('password', None) + keyring_name = config.KEYRING_NAME + if not master_key: + # Generate new one and migration required + master_key = secrets.token_urlsafe(12) - enc_key = data['password'] - if not config.MASTER_PASSWORD_REQUIRED: - status, enc_key = get_crypt_key() - if not status: - raise CryptKeyMissing + # migrate existing server passwords + from pgadmin.browser.server_groups.servers.utils \ + import migrate_saved_passwords + migrated_save_passwords, error = migrate_saved_passwords( + master_key, master_password) - try: - all_server = Server.query.all() - saved_password_servers = [server for server in all_server if - server.save_password] - # pgAdmin will use the OS password manager to store the server - # password, here migrating the existing saved server password to - # OS password manager - if len(saved_password_servers) > 0 and (keyring.get_password( - KEY_RING_SERVICE_NAME, KEY_RING_DESKTOP_USER.format( - desktop_user.username)) or enc_key): - is_migrated = False - - for server in saved_password_servers: - if enc_key: - if server.password and config.ALLOW_SAVE_PASSWORD: - name = KEY_RING_USERNAME_FORMAT.format(server.name, - server.id) - password = decrypt(server.password, - enc_key).decode() - # Store the password using OS password manager - keyring.set_password(KEY_RING_SERVICE_NAME, name, - password) - is_migrated = True - setattr(server, 'password', None) - - if server.tunnel_password and \ - config.ALLOW_SAVE_TUNNEL_PASSWORD: - tname = KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id) - tpassword = decrypt(server.tunnel_password, - enc_key).decode() - # Store the password using OS password manager - keyring.set_password(KEY_RING_SERVICE_NAME, tname, - tpassword) - is_migrated = True - setattr(server, 'tunnel_password', None) - - db.session.commit() - - # Store the password using OS password manager - keyring.set_password(KEY_RING_SERVICE_NAME, - KEY_RING_DESKTOP_USER.format( - desktop_user.username), 'test') - return form_master_password_response( - existing=True, - present=True, - keyring_name=config.KEYRING_NAME if is_migrated else '' - ) - else: - if len(all_server) == 0: - # Store the password using OS password manager - keyring.set_password(KEY_RING_SERVICE_NAME, - KEY_RING_DESKTOP_USER.format( - desktop_user.username), 'test') - return form_master_password_response( - present=True, - ) - else: - is_master_password_present = True - keyring_name = '' - for server in all_server: - is_password_present = \ - server.save_password or server.tunnel_password - if server.password and is_password_present: - is_master_password_present = False - keyring_name = config.KEYRING_NAME - break - - if is_master_password_present: - # Store the password using OS password manager + if migrated_save_passwords: + # Update keyring keyring.set_password(KEY_RING_SERVICE_NAME, - KEY_RING_DESKTOP_USER.format( - desktop_user.username), - 'test') - + KEY_RING_USER_NAME, + master_key) + # set crypt key + set_crypt_key(master_key) + return form_master_password_response( + existing=True, + present=True, + keyring_name=keyring_name) + else: + if not error: + set_crypt_key(master_key) + return form_master_password_response( + present=True) + # Migration failed + elif error != 'Master password required': + errmsg = error + return form_master_password_response( + existing=False, + present=True, + errmsg=errmsg, + keyring_name=keyring_name) + else: + current_app.logger.warning( + ' Master key was already present in the keyring,' + ' hence not doing any migration') + # Key is already generated and set, no migration required + # set crypt key + set_crypt_key(master_key) return form_master_password_response( - present=is_master_password_present, - keyring_name=keyring_name - ) - except Exception as e: - current_app.logger.warning( - 'Fail set password using OS password manager' - ', fallback to master password. Error: {0}'.format(e) - ) - config.DISABLED_LOCAL_PASSWORD_STORAGE = True - - # If the master password is required and the master password hook - # is specified then try to retrieve the encryption key and update data. - # If there is an error while retrieving it, return an error message. - if config.SERVER_MODE and config.MASTER_PASSWORD_REQUIRED and \ - config.MASTER_PASSWORD_HOOK: - status, enc_key = get_crypt_key() - if status: - data = {'password': enc_key, 'submit_password': True} + present=True) + except KeyringLocked as e: + current_app.logger.warning( + 'Failed to set because Access Denied.' + ' Error: {0}'.format(e)) + config.USE_OS_SECRET_STORAGE = False + except Exception as e: + current_app.logger.warning( + 'Failed to set encryption key using OS password manager' + ', fallback to master password. Error: {0}'.format(e)) + # Also if masterpass_check is none it means previously + # passwords were migrated using keyring crypt key. + # Reset all passwords because we are going to master password + # again and while setting master password, all server + # passwords are decrypted using old key before re-encryption + if current_user.masterpass_check is None: + from pgadmin.browser.server_groups.servers.utils \ + import remove_saved_passwords, update_session_manager + remove_saved_passwords(current_user.id) + update_session_manager(current_user.id) + # Disable local os storage if any exception while creation + config.USE_OS_SECRET_STORAGE = False + delete_local_storage_master_key() else: - error = gettext('The master password could not be retrieved from ' - 'the MASTER_PASSWORD_HOOK utility specified {0}.' - 'Please check that the hook utility is configured' - ' correctly.'.format(config.MASTER_PASSWORD_HOOK)) - return form_master_password_response( - existing=False, - present=False, - errmsg=error, - invalid_master_password_hook=True - ) + # if os secret storage disabled now, but was used once then + # remove all the saved passwords + delete_local_storage_master_key() + else: + # If the master password is required and the master password hook + # is specified then try to retrieve the encryption key and update data. + # If there is an error while retrieving it, return an error message. + if config.MASTER_PASSWORD_REQUIRED and config.MASTER_PASSWORD_HOOK: + master_password = get_master_password_from_master_hook() + if master_password: + data = {'password': master_password, 'submit_password': True} + else: + errmsg = gettext( + 'The master password could not be retrieved from the' + ' MASTER_PASSWORD_HOOK utility specified {0}. Please check' + ' that the hook utility is configured correctly.'.format( + config.MASTER_PASSWORD_HOOK)) + return form_master_password_response( + existing=False, + present=False, + errmsg=errmsg, + master_password_hook=config.MASTER_PASSWORD_HOOK, + keyring_name=keyring_name + ) # Master password is applicable for Desktop mode and in server mode # only when auth sources are oauth, kerberos, webserver. @@ -843,20 +813,16 @@ def set_master_password(): if current_user.masterpass_check is not None and \ data.get('submit_password', False) and \ not validate_master_password(data.get('password')): - errmsg = '' if config.MASTER_PASSWORD_HOOK \ - else gettext("Incorrect master password") - invalid_master_password_hook = \ - True if config.MASTER_PASSWORD_HOOK else False return form_master_password_response( existing=True, present=False, errmsg=errmsg, - invalid_master_password_hook=invalid_master_password_hook + master_password_hook=config.MASTER_PASSWORD_HOOK, + keyring_name=keyring_name ) # if master password received in request if data != '' and data.get('password', '') != '': - # store the master pass in the memory set_crypt_key(data.get('password')) @@ -864,7 +830,6 @@ def set_master_password(): # master check is not set, which means the server password # data is old and is encrypted with old key # Re-encrypt with new key - from pgadmin.browser.server_groups.servers.utils \ import reencrpyt_server_passwords reencrpyt_server_passwords( @@ -877,13 +842,14 @@ def set_master_password(): # If password in request is empty then try to get it with # get_crypt_key method. If get_crypt_key() returns false status and - # masterpass_check is already set, provide a pop to enter + # masterpass_check is already set, provide a popup to enter # master password(present) without the reset option.(existing). elif not get_crypt_key()[0] and \ current_user.masterpass_check is not None: return form_master_password_response( existing=True, present=False, + keyring_name=keyring_name ) # If get_crypt_key return True,but crypt_key is none and @@ -896,7 +862,8 @@ def set_master_password(): return form_master_password_response( existing=False, present=False, - errmsg=error_message + errmsg=error_message, + keyring_name=keyring_name ) # if master password is disabled now, but was used once then @@ -904,7 +871,6 @@ def set_master_password(): process_masterpass_disabled() if config.SERVER_MODE and current_user.masterpass_check is None: - crypt_key = get_crypt_key()[1] from pgadmin.browser.server_groups.servers.utils \ import reencrpyt_server_passwords @@ -921,8 +887,6 @@ def set_master_password(): # Only register route if SECURITY_CHANGEABLE is set to True # We can't access app context here so cannot # use app.config['SECURITY_CHANGEABLE'] - - if hasattr(config, 'SECURITY_CHANGEABLE') and config.SECURITY_CHANGEABLE: @blueprint.route("/change_password", endpoint="change_password", methods=['GET', 'POST']) diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 8209a46c5..c1c956522 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -38,11 +38,8 @@ from pgadmin.utils.constants import UNAUTH_REQ, MIMETYPE_APP_JS, \ SERVER_CONNECTION_CLOSED from sqlalchemy import or_ from pgadmin.utils.preferences import Preferences -from pgadmin.utils.constants import KEY_RING_SERVICE_NAME, \ - KEY_RING_USERNAME_FORMAT, KEY_RING_TUNNEL_FORMAT, KEY_RING_DESKTOP_USER from .... import socketio as sio from pgadmin.utils import get_complete_file_path -import keyring def has_any(data, keys): @@ -255,22 +252,6 @@ class ServerModule(sg.ServerGroupPluginModule): except Exception as e: current_app.logger.exception(e) errmsg = str(e) - - is_password_saved = bool(server.save_password) - is_tunnel_password_saved = bool(server.tunnel_password) - - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - sname = KEY_RING_USERNAME_FORMAT.format(server.name, server.id) - spassword = keyring.get_password( - KEY_RING_SERVICE_NAME, sname) - - is_password_saved = bool(spassword) - tunnelname = KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id) - tunnel_password = keyring.get_password(KEY_RING_SERVICE_NAME, - tunnelname) - is_tunnel_password_saved = bool(tunnel_password) - yield self.generate_browser_node( "%d" % (server.id), gid, @@ -287,8 +268,8 @@ class ServerModule(sg.ServerGroupPluginModule): wal_pause=wal_paused, host=server.host, port=server.port, - is_password_saved=is_password_saved, - is_tunnel_password_saved=is_tunnel_password_saved, + is_password_saved=bool(server.save_password), + is_tunnel_password_saved=bool(server.tunnel_password), was_connected=was_connected, errmsg=errmsg, user_id=server.user_id, @@ -605,22 +586,6 @@ class ServerNode(PGChildNodeView): manager.release() errmsg = "{0} : {1}".format(server.name, result) - is_password_saved = bool(server.save_password) - is_tunnel_password_saved = bool(server.tunnel_password) - - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - sname = KEY_RING_USERNAME_FORMAT.format(server.name, server.id) - spassword = keyring.get_password( - KEY_RING_SERVICE_NAME, sname) - - is_password_saved = bool(spassword) - - tunnelname = KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id) - tunnel_password = keyring.get_password(KEY_RING_SERVICE_NAME, - tunnelname) - is_tunnel_password_saved = bool(tunnel_password) - res.append( self.blueprint.generate_browser_node( "%d" % (server.id), @@ -637,8 +602,8 @@ class ServerNode(PGChildNodeView): user=manager.user_info if connected else None, in_recovery=in_recovery, wal_pause=wal_paused, - is_password_saved=is_password_saved, - is_tunnel_password_saved=is_tunnel_password_saved, + is_password_saved=bool(server.save_password), + is_tunnel_password_saved=bool(server.tunnel_password), errmsg=errmsg, username=server.username, shared=server.shared, @@ -761,20 +726,6 @@ class ServerNode(PGChildNodeView): server_name = s.name get_driver(PG_DEFAULT_DRIVER).delete_manager(s.id) db.session.delete(s) - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - try: - sname = KEY_RING_USERNAME_FORMAT.format( - s.name, - s.id) - # Get password form OS password manager - is_present = keyring.get_password( - KEY_RING_SERVICE_NAME, sname) - # Delete saved password from OS password manager - if is_present: - keyring.delete_password(KEY_RING_SERVICE_NAME, - sname) - except keyring.errors.KeyringError as e: - config.DISABLED_LOCAL_PASSWORD_STORAGE = True db.session.commit() self.delete_shared_server(server_name, gid, sid) QueryHistory.clear_history(current_user.id, sid) @@ -888,26 +839,6 @@ class ServerNode(PGChildNodeView): ) try: - if len(old_server_name) and old_server_name != server.name and \ - not config.DISABLED_LOCAL_PASSWORD_STORAGE and \ - server.save_password: - # If server name is changed then update keyring with - # new server name - password = keyring.get_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(old_server_name, - server.id)) - - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(server.name, server.id), - password) - - server_name = KEY_RING_USERNAME_FORMAT.format( - old_server_name, server.id) - # Delete saved password from OS password manager - keyring.delete_password(KEY_RING_SERVICE_NAME, - server_name) db.session.commit() except Exception as e: current_app.logger.exception(e) @@ -1175,11 +1106,10 @@ class ServerNode(PGChildNodeView): if data[item] == '': data[item] = None - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - # Get enc key - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - raise CryptKeyMissing + # Get enc key + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + raise CryptKeyMissing # Some fields can be provided with service file so they are optional if 'service' in data and not data['service']: @@ -1276,9 +1206,7 @@ class ServerNode(PGChildNodeView): # login with password have_password = True password = data['password'] - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - password = encrypt(password, crypt_key) - + password = encrypt(password, crypt_key) elif 'passfile' in data['connection_params'] and \ data['connection_params']['passfile'] != '': passfile = data['connection_params']['passfile'] @@ -1286,10 +1214,7 @@ class ServerNode(PGChildNodeView): if 'tunnel_password' in data and data["tunnel_password"] != '': have_tunnel_password = True tunnel_password = data['tunnel_password'] - - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - tunnel_password = \ - encrypt(tunnel_password, crypt_key) + tunnel_password = encrypt(tunnel_password, crypt_key) status, errmsg = conn.connect( password=password, @@ -1310,32 +1235,15 @@ class ServerNode(PGChildNodeView): else: if 'save_password' in data and data['save_password'] and \ have_password and config.ALLOW_SAVE_PASSWORD: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - setattr(server, 'password', password) - db.session.commit() - else: - # Store the password using OS password manager - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(server.name, - server.id), - password) + setattr(server, 'password', password) + db.session.commit() if 'save_tunnel_password' in data and \ data['save_tunnel_password'] and \ have_tunnel_password and \ config.ALLOW_SAVE_TUNNEL_PASSWORD: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - setattr(server, 'tunnel_password', tunnel_password) - db.session.commit() - else: - # Store the password using OS password manager - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id), - tunnel_password) - tunnel_password_saved = True + setattr(server, 'tunnel_password', tunnel_password) + db.session.commit() replication_type = get_replication_type(conn, manager.version) @@ -1540,39 +1448,18 @@ class ServerNode(PGChildNodeView): conn = manager.connection() crypt_key = None - if server.save_password: - if config.DISABLED_LOCAL_PASSWORD_STORAGE or \ - not keyring.get_password( - KEY_RING_SERVICE_NAME, - KEY_RING_DESKTOP_USER.format(current_user.username)): - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - raise CryptKeyMissing - - else: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - # Get enc key - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - raise CryptKeyMissing + # Get enc key + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + raise CryptKeyMissing # If server using SSH Tunnel if server.use_ssh_tunnel: - if 'tunnel_password' not in data: - if config.DISABLED_LOCAL_PASSWORD_STORAGE \ - and server.tunnel_password is None: + if server.tunnel_password is None: prompt_tunnel_password = True else: - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - # Get password form OS password manager - tunnel_password = keyring.get_password( - KEY_RING_SERVICE_NAME, - KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id)) - prompt_tunnel_password = not bool(tunnel_password) - else: - tunnel_password = server.tunnel_password + tunnel_password = server.tunnel_password else: tunnel_password = data['tunnel_password'] \ if 'tunnel_password' in data else '' @@ -1582,11 +1469,9 @@ class ServerNode(PGChildNodeView): # Encrypt the password before saving with user's login # password key. try: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - tunnel_password = encrypt(tunnel_password, crypt_key) \ - if tunnel_password is not None else \ - server.tunnel_password - + tunnel_password = encrypt(tunnel_password, crypt_key) \ + if tunnel_password is not None else \ + server.tunnel_password except Exception as e: current_app.logger.exception(e) return internal_server_error(errormsg=str(e)) @@ -1608,43 +1493,20 @@ class ServerNode(PGChildNodeView): get_complete_file_path(passfile_param): passfile = passfile_param else: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - password = conn_passwd or server.password - else: - # Get password form OS password manager - password = keyring.get_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(server.name, - server.id)) - prompt_password = ( - True - if password is None and server.passexec_cmd is None - else False - ) + password = conn_passwd or server.password else: password = data['password'] if 'password' in data else None save_password = data['save_password']\ if 'save_password' in data else False - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - try: - # Encrypt the password before saving with user's login - # password key. - password = encrypt(password, crypt_key) \ - if password is not None else server.password - except Exception as e: - current_app.logger.exception(e) - return internal_server_error(errormsg=str(e)) - elif save_password and config.ALLOW_SAVE_PASSWORD: - # Store the password using OS password manager - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format( - server.name, server.id), password) - # Get password form OS password manager - password = keyring.get_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(server.name, server.id)) + try: + # Encrypt the password before saving with user's login + # password key. + password = encrypt(password, crypt_key) \ + if password is not None else server.password + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) # Check do we need to prompt for the database server or ssh tunnel # password or both. Return the password template in case password is @@ -1691,7 +1553,7 @@ class ServerNode(PGChildNodeView): # Save the encrypted password using the user's login # password key, if there is any password to save - if password and config.DISABLED_LOCAL_PASSWORD_STORAGE: + if password: if server.shared and server.user_id != current_user.id: setattr(shared_server, 'password', password) else: @@ -1707,18 +1569,8 @@ class ServerNode(PGChildNodeView): if save_tunnel_password and config.ALLOW_SAVE_TUNNEL_PASSWORD: try: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - # Save the encrypted tunnel password. - setattr(server, 'tunnel_password', tunnel_password) - else: - # Store the password using OS password manager - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id), - tunnel_password) - setattr(server, 'tunnel_password', None) - + # Save the encrypted tunnel password. + setattr(server, 'tunnel_password', tunnel_password) db.session.commit() except Exception as e: # Release Connection @@ -1881,28 +1733,16 @@ class ServerNode(PGChildNodeView): elif request.data: data = json.loads(request.data) - crypt_key = None - - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - # Get enc key - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - raise CryptKeyMissing + # Get enc key + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + raise CryptKeyMissing # Fetch Server Details server = Server.query.filter_by(id=sid).first() - if server is None: return bad_request(self.not_found_error_msg()) - spassword = None - if not config.DISABLED_LOCAL_PASSWORD_STORAGE and \ - bool(server.save_password): - sname = KEY_RING_USERNAME_FORMAT.format(server.name, - server.id) - spassword = keyring.get_password( - KEY_RING_SERVICE_NAME, sname) - # Fetch User Details. user = User.query.filter_by(id=current_user.id).first() if user is None: @@ -1914,9 +1754,9 @@ class ServerNode(PGChildNodeView): # If there is no password found for the server # then check for pgpass file - if (not server.password or spassword) and \ - not manager.password and hasattr(server, 'connection_params') \ - and 'passfile' in server.connection_params and \ + if not server.password and not manager.password and \ + hasattr(server, 'connection_params') and \ + 'passfile' in server.connection_params and \ manager.get_connection_param_value('passfile') and \ server.connection_params['passfile'] == \ manager.get_connection_param_value('passfile'): @@ -1956,17 +1796,9 @@ class ServerNode(PGChildNodeView): # Check against old password only if no pgpass file if not is_passfile: - if not config.DISABLED_LOCAL_PASSWORD_STORAGE: - if spassword: - decrypted_password = spassword - else: - decrypted_password = manager.password - else: - decrypted_password = decrypt(manager.password, crypt_key) - - if isinstance(decrypted_password, bytes): - decrypted_password = decrypted_password.decode() - + decrypted_password = decrypt(manager.password, crypt_key) + if isinstance(decrypted_password, bytes): + decrypted_password = decrypted_password.decode() password = data['password'] # Validate old password before setting new. @@ -1998,17 +1830,7 @@ class ServerNode(PGChildNodeView): # Store password in sqlite only if no pgpass file if not is_passfile: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - password = encrypt(data['newPassword'], crypt_key) - elif not config.DISABLED_LOCAL_PASSWORD_STORAGE: - if config.ALLOW_SAVE_PASSWORD and bool( - server.save_password): - keyring.set_password( - KEY_RING_SERVICE_NAME, - KEY_RING_USERNAME_FORMAT.format(server.name, - server.id), - data['newPassword']) - password = data['newPassword'] + password = encrypt(data['newPassword'], crypt_key) # Check if old password was stored in pgadmin4 sqlite database. # If yes then update that password. if server.password is not None and config.ALLOW_SAVE_PASSWORD: @@ -2232,25 +2054,10 @@ class ServerNode(PGChildNodeView): server = ServerModule. \ get_shared_server_properties(server, shared_server) - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - if server.shared and server.user_id != current_user.id: - setattr(shared_server, 'password', None) - else: - setattr(server, 'password', None) + if server.shared and server.user_id != current_user.id: + setattr(shared_server, 'password', None) else: - try: - server_name = KEY_RING_USERNAME_FORMAT.format(server.name, - server.id) - # Get password form OS password manager - is_present = keyring.get_password(KEY_RING_SERVICE_NAME, - server_name) - if is_present: - # Delete saved password from OS password manager - keyring.delete_password(KEY_RING_SERVICE_NAME, - server_name) - except keyring.errors.KeyringError as e: - config.DISABLED_LOCAL_PASSWORD_STORAGE = True - setattr(server, 'save_password', None) + setattr(server, 'password', None) # If password was saved then clear the flag also # 0 is False in SQLite db @@ -2289,19 +2096,8 @@ class ServerNode(PGChildNodeView): success=0, info=self.not_found_error_msg() ) - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - setattr(server, 'tunnel_password', None) - db.session.commit() - else: - server_name = KEY_RING_TUNNEL_FORMAT.format(server.name, - server.id) - # Get password form OS password manager - is_present = keyring.get_password(KEY_RING_SERVICE_NAME, - server_name) - if is_present: - # Delete saved password from OS password manager - keyring.delete_password(KEY_RING_SERVICE_NAME, server_name) - setattr(server, 'tunnel_password', None) + setattr(server, 'tunnel_password', None) + db.session.commit() except Exception as e: current_app.logger.error( "Unable to clear ssh tunnel password." diff --git a/web/pgadmin/browser/server_groups/servers/utils.py b/web/pgadmin/browser/server_groups/servers/utils.py index d5c869b55..e34bfa2db 100644 --- a/web/pgadmin/browser/server_groups/servers/utils.py +++ b/web/pgadmin/browser/server_groups/servers/utils.py @@ -9,12 +9,20 @@ """Server helper utilities""" from ipaddress import ip_address +import keyring +from flask_login import current_user from werkzeug.exceptions import InternalServerError from flask import render_template - +from pgadmin.utils.constants import KEY_RING_USERNAME_FORMAT, \ + KEY_RING_SERVICE_NAME, KEY_RING_USER_NAME, KEY_RING_TUNNEL_FORMAT, \ + KEY_RING_DESKTOP_USER from pgadmin.utils.crypto import encrypt, decrypt import config from pgadmin.model import db, Server +from flask import current_app +from pgadmin.utils.exception import CryptKeyMissing +from pgadmin.utils.master_password import validate_master_password, \ + get_crypt_key, set_masterpass_check_text def is_valid_ipaddress(address): @@ -232,6 +240,157 @@ def _password_check(server, manager, old_key, new_key): manager.password = password +def migrate_passwords_from_os_secret_storage(servers, enc_key): + """ + Migrate password stored in os secret storage + :param servers: server list + :param enc_key: new encryption key + :return: True if successful else False + """ + passwords_migrated = False + error = '' + try: + if len(servers) > 0: + for server in servers: + server_name = KEY_RING_USERNAME_FORMAT.format(server.name, + server.id) + server_password = keyring.get_password( + KEY_RING_SERVICE_NAME, server_name) + if server_password: + server_password = encrypt(server_password, enc_key) + setattr(server, 'password', server_password) + else: + setattr(server, 'save_password', 0) + + tunnel_name = KEY_RING_TUNNEL_FORMAT.format(server.name, + server.id) + tunnel_password = keyring.get_password( + KEY_RING_SERVICE_NAME, tunnel_name) + if tunnel_password: + setattr(server, 'tunnel_password', tunnel_password) + keyring.delete_password( + KEY_RING_SERVICE_NAME, tunnel_name) + else: + setattr(server, 'tunnel_password', None) + passwords_migrated = True + except Exception as e: + error = 'Failed to migrate passwords stored using OS' \ + ' password manager.Error: {0}'.format(e) + current_app.logger.warning(error) + return passwords_migrated, error + + +def migrate_passwords_from_pgadmin_db(servers, old_key, enc_key): + """ + Migrates passwords stored in pgadmin db + :param servers: list of servers + :param old_key: old encryption key + :param enc_key: new encryption key + :return: True if successful else False + """ + error = '' + passwords_migrated = False + try: + for ser in servers: + if ser.password: + password = decrypt(ser.password, old_key).decode() + server_password = encrypt(password, enc_key) + setattr(ser, 'password', server_password) + + if ser.tunnel_password: + password = decrypt(ser.tunnel_password, old_key).decode() + tunnel_password = encrypt(password, enc_key) + setattr(ser, 'tunnel_password', tunnel_password) + passwords_migrated = True + except Exception as e: + error = 'Failed to migrate passwords stored using master password or' \ + ' user password password manager. Error: {0}'.format(e) + current_app.logger.warning(error) + config.USE_OS_SECRET_STORAGE = False + + return passwords_migrated, error + + +def migrate_saved_passwords(master_key, master_password): + """ + Function will migrate password stored in pgadmin db and os secret storage + with separate entry for each server(initial keyring implementation #5123). + Now all saved passwords will be stored in pgadmin db which are encrypted + using master_key which is stored in local os storage. + :param master_key: encryption key from local os storage + :param master_password: set by user if MASTER_PASSWORD_REQUIRED=True + :param old_crypt_key: enc_key with ith passwords were encrypted when + MASTER_PASSWORD_REQUIRED=False + :return: True if all passwords are migrated successfully. + """ + error = '' + old_key = None + passwords_migrated = False + if config.ALLOW_SAVE_PASSWORD or config.ALLOW_SAVE_TUNNEL_PASSWORD: + # Get servers with saved password + all_server = Server.query.all() + saved_password_servers = [ser for ser in all_server + if ser.save_password or ser.tunnel_password] + + servers_with_pwd_in_os_secret = [] + servers_with_pwd_in_pgadmin_db = [] + for ser in saved_password_servers: + if ser.password is None: + servers_with_pwd_in_os_secret.append(ser) + else: + servers_with_pwd_in_pgadmin_db.append(ser) + + # No server passwords are saved + if len(saved_password_servers) == 0: + current_app.logger.warning( + 'There are no saved passwords') + return passwords_migrated, error + + # If not master password received return and follow + # normal Master password path + if config.MASTER_PASSWORD_REQUIRED: + if current_user.masterpass_check is not None and \ + not master_password: + error = 'Master password required' + return passwords_migrated, error + elif master_password: + old_key = master_password + else: + old_key = current_user.password + + # servers passwords stored with os storage are present. + if len(servers_with_pwd_in_os_secret) > 0: + current_app.logger.warning( + 'Re-encrypting passwords saved using os password manager') + passwords_migrated, error = \ + migrate_passwords_from_os_secret_storage( + servers_with_pwd_in_os_secret, master_key) + + if len(servers_with_pwd_in_pgadmin_db) > 0 and old_key: + # if master_password present and masterpass_check is present, + # server passwords are encrypted with master password + current_app.logger.warning( + 'Re-encrypting passwords saved using master password') + passwords_migrated, error = migrate_passwords_from_pgadmin_db( + servers_with_pwd_in_pgadmin_db, old_key, master_key) + # clear master_pass check once passwords are migrated + if passwords_migrated: + set_masterpass_check_text('', clear=True) + + if passwords_migrated: + # commit the changes once all are migrated + db.session.commit() + # Delete passwords from os password manager + if len(servers_with_pwd_in_os_secret) > 0: + delete_saved_passwords_from_os_secret_storage( + servers_with_pwd_in_os_secret) + # Update driver manager with new passwords + update_session_manager(saved_password_servers) + current_app.logger.warning('Password migration is successful') + + return passwords_migrated, error + + def reencrpyt_server_passwords(user_id, old_key, new_key): """ This function will decrypt the saved passwords in SQLite with old key @@ -242,7 +401,6 @@ def reencrpyt_server_passwords(user_id, old_key, new_key): for server in Server.query.filter_by(user_id=user_id).all(): manager = driver.connection_manager(server.id) - _password_check(server, manager, old_key, new_key) if server.tunnel_password is not None: @@ -274,13 +432,88 @@ def remove_saved_passwords(user_id): try: db.session.query(Server) \ .filter(Server.user_id == user_id) \ - .update({Server.password: None, Server.tunnel_password: None}) + .update({Server.password: None, Server.tunnel_password: None, + Server.save_password: 0}) db.session.commit() except Exception: db.session.rollback() raise +def delete_saved_passwords_from_os_secret_storage(servers): + """ + Delete passwords from os secret storage + :param servers: server list + :return: True if successful else False + """ + try: + # Clears entry created by initial keyring implementation + desktop_user_pass = \ + KEY_RING_DESKTOP_USER.format(current_user.username) + if keyring.get_password(KEY_RING_SERVICE_NAME,desktop_user_pass): + keyring.delete_password(KEY_RING_SERVICE_NAME, desktop_user_pass) + + if len(servers) > 0: + for server in servers: + server_name = KEY_RING_USERNAME_FORMAT.format(server.name, + server.id) + server_password = keyring.get_password( + KEY_RING_SERVICE_NAME, server_name) + if server_password: + keyring.delete_password( + KEY_RING_SERVICE_NAME, server_name) + else: + setattr(server, 'save_password', 0) + + tunnel_name = KEY_RING_TUNNEL_FORMAT.format(server.name, + server.id) + tunnel_password = keyring.get_password( + KEY_RING_SERVICE_NAME, tunnel_name) + if tunnel_password: + keyring.delete_password( + KEY_RING_SERVICE_NAME, tunnel_name) + else: + setattr(server, 'tunnel_password', None) + return True + else: + # This means no server password to migrate + return False + except Exception as e: + current_app.logger.warning( + 'Failed to delete passwords stored in OS password manager.' + 'Error: {0}'.format(e)) + return False + + +def update_session_manager(user_id=None, servers=None): + """ + Updates the passwords in the session + :param user_id: + :param servers: + :return: + """ + from pgadmin.model import Server + from pgadmin.utils.driver import get_driver + driver = get_driver(config.PG_DEFAULT_DRIVER) + try: + if user_id: + for server in Server.query.\ + filter_by(user_id=current_user.id).all(): + manager = driver.connection_manager(server.id) + manager.update(server) + elif servers: + for server in servers: + manager = driver.connection_manager(server.id) + manager.update(server) + else: + return False + db.session.commit() + return True + except Exception: + db.session.rollback() + raise + + def get_replication_type(conn, sversion): status, res = conn.execute_dict(render_template( "/servers/sql/#{0}#/replication_type.sql".format(sversion) diff --git a/web/pgadmin/browser/tests/test_master_password.py b/web/pgadmin/browser/tests/test_master_password.py index 0834aa5f8..2441e47a0 100644 --- a/web/pgadmin/browser/tests/test_master_password.py +++ b/web/pgadmin/browser/tests/test_master_password.py @@ -45,6 +45,7 @@ class MasterPasswordTestCase(BaseTestGenerator): def setUp(self): config.MASTER_PASSWORD_REQUIRED = True config.AUTHENTICATION_SOURCES = [INTERNAL] + config.USE_OS_SECRET_STORAGE = False def runTest(self): """This function will check change password functionality.""" diff --git a/web/pgadmin/evaluate_config.py b/web/pgadmin/evaluate_config.py index e035a899a..54475eac6 100644 --- a/web/pgadmin/evaluate_config.py +++ b/web/pgadmin/evaluate_config.py @@ -116,15 +116,15 @@ def evaluate_and_patch_config(config: dict) -> dict: config['ENABLE_PSQL'] = True if config.get('SERVER_MODE'): - config.setdefault('DISABLED_LOCAL_PASSWORD_STORAGE', True) + config.setdefault('USE_OS_SECRET_STORAGE', False) config.setdefault('KEYRING_NAME', '') else: k_name = keyring.get_keyring().name + # Setup USE_OS_SECRET_STORAGE false as no keyring backend available if k_name == 'fail Keyring': - config.setdefault('DISABLED_LOCAL_PASSWORD_STORAGE', True) - config.setdefault('KEYRING_NAME', '') + config['USE_OS_SECRET_STORAGE'] = False + config['KEYRING_NAME'] = '' else: - config.setdefault('DISABLED_LOCAL_PASSWORD_STORAGE', False) config.setdefault('KEYRING_NAME', k_name) config.setdefault('SESSION_COOKIE_PATH', config.get('COOKIE_DEFAULT_PATH')) diff --git a/web/pgadmin/static/js/Dialogs/MasterPasswordContent.jsx b/web/pgadmin/static/js/Dialogs/MasterPasswordContent.jsx index 8c62d2dc1..d41d664af 100644 --- a/web/pgadmin/static/js/Dialogs/MasterPasswordContent.jsx +++ b/web/pgadmin/static/js/Dialogs/MasterPasswordContent.jsx @@ -64,7 +64,7 @@ export default function MasterPasswordContent({ closeModal, onResetPassowrd, onO
- + diff --git a/web/pgadmin/static/js/Dialogs/index.jsx b/web/pgadmin/static/js/Dialogs/index.jsx index 81e17042f..a3a12451d 100644 --- a/web/pgadmin/static/js/Dialogs/index.jsx +++ b/web/pgadmin/static/js/Dialogs/index.jsx @@ -81,39 +81,20 @@ export function checkMasterPassword(data, masterpass_callback_queue, cancel_call const api = getApiInstance(); api.post(url_for('browser.set_master_password'), data).then((res)=> { let isKeyring = res.data.data.keyring_name.length > 0; + let error = res.data.data.errmsg; if(!res.data.data.present) { - if (res.data.data.invalid_master_password_hook){ - if(res.data.data.is_error){ - pgAdmin.Browser.notifier.error(res.data.data.errmsg); - }else{ - pgAdmin.Browser.notifier.confirm(gettext('Reset Master Password'), - gettext('The master password retrieved from the master password hook utility is different from what was previously retrieved.') + '
' - + gettext('Do you want to reset your master password to match?') + '

' - + gettext('Note that this will close all open database connections and remove all saved passwords.'), - function() { - let _url = url_for('browser.reset_master_password'); - api.delete(_url) - .then(() => { - pgAdmin.Browser.notifier.info('The master password has been reset.'); - }) - .catch((err) => { - pgAdmin.Browser.notifier.error(err.message); - }); - return true; - }, - function() {/* If user clicks No */ return true;} - );} - }else{ - showMasterPassword(res.data.data.reset, res.data.data.errmsg, masterpass_callback_queue, cancel_callback, res.data.data.keyring_name); - } - + showMasterPassword(res.data.data.reset, res.data.data.errmsg, masterpass_callback_queue, cancel_callback, res.data.data.keyring_name, res.data.data.master_password_hook); } else { masterPassCallbacks(masterpass_callback_queue); - if(isKeyring) { - pgAdmin.Browser.notifier.alert(gettext('Migration successful'), - gettext(`Passwords previously saved by pgAdmin have been successfully migrated to ${res.data.data.keyring_name} and removed from the pgAdmin store.`)); + if(error){ + pgAdmin.Browser.notifier.alert(gettext('Migration failed'), + gettext(`Passwords previously saved can not be re-encrypted using encryption key stored in the ${res.data.data.keyring_name}. due to ${error}`)); + }else{ + pgAdmin.Browser.notifier.alert(gettext('Migration successful'), + gettext(`Passwords previously saved are re-encrypted using encryption key stored in the ${res.data.data.keyring_name}.`)); + } } } }).catch(function(error) { @@ -122,7 +103,7 @@ export function checkMasterPassword(data, masterpass_callback_queue, cancel_call } // This functions is used to show the master password dialog. -export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_queue, cancel_callback, keyring_name='') { +export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_queue, cancel_callback, keyring_name='', master_password_hook='') { const api = getApiInstance(); let title = gettext('Set Master Password'); if (keyring_name.length > 0) @@ -130,47 +111,73 @@ export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_que else if (isPWDPresent) title = gettext('Unlock Saved Passwords'); - pgAdmin.Browser.notifier.showModal(title, (onClose)=> { - return ( - { - onClose(); - }} - onResetPassowrd={(isKeyRing=false)=>{ - pgAdmin.Browser.notifier.confirm(gettext('Reset Master Password'), - gettext('This will remove all the saved passwords. This will also remove established connections to ' - + 'the server and you may need to reconnect again. Do you wish to continue?'), - function() { - let _url = url_for('browser.reset_master_password'); + if(master_password_hook){ + if(errmsg){ + pgAdmin.Browser.notifier.error(errmsg); + return true; + }else{ + pgAdmin.Browser.notifier.confirm(gettext('Reset Master Password'), + gettext('The master password retrieved from the master password hook utility is different from what was previously retrieved.') + '
' + + gettext('Do you want to reset your master password to match?') + '

' + + gettext('Note that this will close all open database connections and remove all saved passwords.'), + function() { + let _url = url_for('browser.reset_master_password'); + const api = getApiInstance(); + api.delete(_url) + .then(() => { + pgAdmin.Browser.notifier.info('The master password has been reset.'); + }) + .catch((err) => { + pgAdmin.Browser.notifier.error(err.message); + }); + return true; + }, + function() {/* If user clicks No */ return true;} + );} + }else{ - api.delete(_url) - .then(() => { - onClose(); - if(!isKeyRing) { - showMasterPassword(false, null, masterpass_callback_queue, cancel_callback); - } - }) - .catch((err) => { - pgAdmin.Browser.notifier.error(err.message); - }); - return true; - }, - function() {/* If user clicks No */ return true;} - ); - }} - onCancel={()=>{ - cancel_callback?.(); - }} - onOK={(formData) => { - onClose(); - checkMasterPassword(formData, masterpass_callback_queue, cancel_callback); - }} - /> - ); - }); + pgAdmin.Browser.notifier.showModal(title, (onClose)=> { + return ( + { + onClose(); + }} + onResetPassowrd={(isKeyRing=false)=>{ + pgAdmin.Browser.notifier.confirm(gettext('Reset Master Password'), + gettext('This will remove all the saved passwords. This will also remove established connections to ' + + 'the server and you may need to reconnect again. Do you wish to continue?'), + function() { + let _url = url_for('browser.reset_master_password'); + + api.delete(_url) + .then(() => { + onClose(); + if(!isKeyRing) { + showMasterPassword(false, null, masterpass_callback_queue, cancel_callback); + } + }) + .catch((err) => { + pgAdmin.Browser.notifier.error(err.message); + }); + return true; + }, + function() {/* If user clicks No */ return true;} + ); + }} + onCancel={()=>{ + cancel_callback?.(); + }} + onOK={(formData) => { + onClose(); + checkMasterPassword(formData, masterpass_callback_queue, cancel_callback); + }} + /> + ); + }); + } } export function showChangeServerPassword() { diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index 012b42211..f5e5672dd 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -134,6 +134,7 @@ ACCESS_DENIED_MESSAGE = gettext( KEY_RING_SERVICE_NAME = 'pgAdmin4' +KEY_RING_USER_NAME = 'pgadmin4-master-password' KEY_RING_USERNAME_FORMAT = KEY_RING_SERVICE_NAME + '-{0}-{1}' KEY_RING_TUNNEL_FORMAT = KEY_RING_SERVICE_NAME + '-tunnel-{0}-{1}' KEY_RING_DESKTOP_USER = KEY_RING_SERVICE_NAME + '-desktop-user-{0}' diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index 11a1865a9..a81c277b3 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -268,18 +268,11 @@ class Connection(BaseConnection): manager = self.manager - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - crypt_key_present, crypt_key = get_crypt_key() - - if not crypt_key_present: - raise CryptKeyMissing() - - password, encpass, is_update_password = self._check_user_password( - kwargs) - else: - password = None - encpass = kwargs['password'] if 'password' in kwargs else None - is_update_password = True + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + raise CryptKeyMissing() + password, encpass, is_update_password = \ + self._check_user_password(kwargs) passfile = kwargs['passfile'] if 'passfile' in kwargs else None tunnel_password = kwargs['tunnel_password'] if 'tunnel_password' in \ @@ -305,15 +298,13 @@ class Connection(BaseConnection): if self.reconnecting is not False: self.password = None - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - is_error, errmsg, password = self._decode_password(encpass, - manager, - password, - crypt_key) - if is_error: - return False, errmsg - else: - password = encpass + if not crypt_key_present: + raise CryptKeyMissing() + + is_error, errmsg, password = self._decode_password( + encpass, manager, password, crypt_key) + if is_error: + return False, errmsg # If no password credential is found then connect request might # come from Query tool, ViewData grid, debugger etc tools. @@ -1580,13 +1571,11 @@ Failed to reset the connection to the server due to following error: user = User.query.filter_by(id=current_user.id).first() if user is None: return False, self.UNAUTHORIZED_REQUEST - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - return False, crypt_key - password = decrypt(password, crypt_key)\ - .decode() + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + return False, crypt_key + password = decrypt(password, crypt_key).decode() try: with ConnectionLocker(self.manager.kerberos_conn): diff --git a/web/pgadmin/utils/driver/psycopg3/server_manager.py b/web/pgadmin/utils/driver/psycopg3/server_manager.py index bdedfdffd..5659f6790 100644 --- a/web/pgadmin/utils/driver/psycopg3/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg3/server_manager.py @@ -242,8 +242,7 @@ WHERE db.oid = {0}""".format(did)) "Could not find the specified database." )) - if not get_crypt_key()[0] and ( - config.SERVER_MODE or config.DISABLED_LOCAL_PASSWORD_STORAGE): + if not get_crypt_key()[0]: # the reason its not connected might be missing key raise CryptKeyMissing() @@ -537,16 +536,10 @@ WHERE db.oid = {0}""".format(did)) def export_password_env(self, env): if self.password: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - return False, crypt_key - password = decrypt(self.password, crypt_key).decode() - elif hasattr(self.password, 'decode'): - password = self.password.decode('utf-8') - else: - password = self.password - + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + return False, crypt_key + password = decrypt(self.password, crypt_key).decode() os.environ[str(env)] = password elif self.passexec: password = self.passexec.get() @@ -565,18 +558,15 @@ WHERE db.oid = {0}""".format(did)) return False, gettext("Unauthorized request.") if tunnel_password is not None and tunnel_password != '': - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - crypt_key_present, crypt_key = get_crypt_key() - if not crypt_key_present: - raise CryptKeyMissing() + crypt_key_present, crypt_key = get_crypt_key() + if not crypt_key_present: + raise CryptKeyMissing() try: - if config.DISABLED_LOCAL_PASSWORD_STORAGE: - tunnel_password = decrypt(tunnel_password, crypt_key) - # password is in bytes, for python3 we need it in string - if isinstance(tunnel_password, bytes): - tunnel_password = tunnel_password.decode() - + tunnel_password = decrypt(tunnel_password, crypt_key) + # password is in bytes, for python3 we need it in string + if isinstance(tunnel_password, bytes): + tunnel_password = tunnel_password.decode() except Exception as e: current_app.logger.exception(e) return False, gettext("Failed to decrypt the SSH tunnel " diff --git a/web/pgadmin/utils/master_password.py b/web/pgadmin/utils/master_password.py index 0a94c7371..3ebc5ea28 100644 --- a/web/pgadmin/utils/master_password.py +++ b/web/pgadmin/utils/master_password.py @@ -1,10 +1,15 @@ +import secrets + +import keyring +from keyring.errors import KeyringError, KeyringLocked, NoKeyringError + import config -from flask import current_app, session, current_app +from flask import current_app from flask_login import current_user from pgadmin.model import db, User, Server +from pgadmin.utils.constants import KEY_RING_SERVICE_NAME, KEY_RING_USER_NAME from pgadmin.utils.crypto import encrypt, decrypt - MASTERPASS_CHECK_TEXT = 'ideas are bulletproof' @@ -23,29 +28,41 @@ def get_crypt_key(): :return: the key """ enc_key = current_app.keyManager.get() - # if desktop mode and master pass disabled then use the password hash - if not config.MASTER_PASSWORD_REQUIRED \ - and not config.SERVER_MODE: + if not config.MASTER_PASSWORD_REQUIRED and\ + not config.USE_OS_SECRET_STORAGE and not config.SERVER_MODE: return True, current_user.password # if desktop mode and master pass enabled elif config.MASTER_PASSWORD_REQUIRED and \ - config.MASTER_PASSWORD_HOOK is None\ - and enc_key is None: + enc_key is None: return False, None - elif not config.MASTER_PASSWORD_REQUIRED and config.SERVER_MODE and \ - 'pass_enc_key' in session: - return True, session['pass_enc_key'] - elif config.MASTER_PASSWORD_REQUIRED and config.SERVER_MODE and \ - config.MASTER_PASSWORD_HOOK and current_user.password is None: - cmd = config.MASTER_PASSWORD_HOOK - command = cmd.replace('%u', current_user.username) \ - if '%u' in cmd else cmd - return get_master_password_from_master_hook(command) else: return True, enc_key +def get_master_password_key_from_os_secret(): + master_key = None + try: + # Try to get master key is from local os storage + master_key = keyring.get_password( + KEY_RING_SERVICE_NAME, KEY_RING_USER_NAME) + except KeyringLocked as e: + current_app.logger.warning( + 'Failed to retrieve master key because Access Denied.' + ' Error: {0}'.format(e)) + config.USE_OS_SECRET_STORAGE = False + except Exception as e: + current_app.logger.warning( + 'Failed to set encryption key using OS password manager' + ', fallback to master password. Error: {0}'.format(e)) + config.USE_OS_SECRET_STORAGE = False + return master_key + + +def generate_master_password_key_for_os_secret(): + return secrets.token_urlsafe(12) + + def validate_master_password(password): """ Validate the password/key against the stored encrypted text @@ -114,6 +131,47 @@ def cleanup_master_password(): manager.update(server) +def delete_local_storage_master_key(): + """ + Deletes the auto generated master key stored in keyring + """ + if not config.SERVER_MODE and not config.USE_OS_SECRET_STORAGE: + # Retrieve from os secret storage + try: + # try to get key + master_key = keyring.get_password(KEY_RING_SERVICE_NAME, + KEY_RING_USER_NAME) + if master_key: + keyring.delete_password(KEY_RING_SERVICE_NAME, + KEY_RING_USER_NAME) + from pgadmin.browser.server_groups.servers.utils \ + import remove_saved_passwords + remove_saved_passwords(current_user.id) + + from pgadmin.utils.driver import get_driver + driver = get_driver(config.PG_DEFAULT_DRIVER) + for server in Server.query.filter_by( + user_id=current_user.id).all(): + manager = driver.connection_manager(server.id) + manager.update(server) + current_app.logger.warning( + 'Deleted master key stored in OS password manager.') + except NoKeyringError as e: + current_app.logger.warning( + ' Failed to delete master key stored in OS password manager' + ' because Keyring backend not found. Error: {0}'.format(e)) + config.USE_OS_SECRET_STORAGE = False + except KeyringLocked as e: + current_app.logger.warning( + ' Failed to delete master key stored in OS password manager' + ' because of Access Denied. Error: {0}'.format(e)) + config.USE_OS_SECRET_STORAGE = False + except Exception as e: + current_app.logger.warning( + 'Failed to delete master key stored in OS password manager.') + config.USE_OS_SECRET_STORAGE = False + + def process_masterpass_disabled(): """ On master password disable, remove the connection data from session as it @@ -129,28 +187,30 @@ def process_masterpass_disabled(): return False -def get_master_password_from_master_hook(command): +def get_master_password_from_master_hook(): """ This method executes specified command & returns output. :param command: Shell command with absolute path :return: Output of command. """ import subprocess + cmd = config.MASTER_PASSWORD_HOOK + command = cmd.replace('%u', current_user.username) \ + if '%u' in cmd else cmd try: p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) out, err = p.communicate() if p.returncode == 0: output = out.decode() if hasattr(out, 'decode') else out output = output.strip() - return True, output + return output else: error = "Command '{0}' failed, exit-code={1} error = {2}".format( command, p.returncode, str(err)) current_app.logger.error(error) - return False, None except Exception as e: current_app.logger.exception( 'Failed to retrieve master password from the master password hook' ' utility.Error: {0}'.format(e) ) - return False, None + return None diff --git a/web/regression/runtests.py b/web/regression/runtests.py index cf6c3a7be..7aecc0417 100644 --- a/web/regression/runtests.py +++ b/web/regression/runtests.py @@ -60,6 +60,7 @@ if config.SERVER_MODE is True: # disable master password for test cases config.MASTER_PASSWORD_REQUIRED = False +config.USE_OS_SECRET_STORAGE = False from regression import test_setup from regression.feature_utils.app_starter import AppStarter