1. Added Master Password to increase the security of saved passwords. Fixes #4184

2. In server(web) mode, update all the saved server credentials when user password is changed. Fixes #3377
This commit is contained in:
Aditya Toshniwal 2019-05-28 12:00:18 +05:30 committed by Akshay Joshi
parent 6f0eafb223
commit dfa892d2a2
44 changed files with 1509 additions and 416 deletions

View File

@ -24,6 +24,13 @@ dialog, right-click on the *Servers* node of the tree control, and select
server_dialog
A master password is required to secure and later unlock saved server passwords.
It is set by the user and can be disabled using config.
.. toctree::
master_password
After defining a server connection, right-click on the server name, and select
*Connect to server* to authenticate with the server, and start using pgAdmin to
manage objects that reside on the server.

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -16,6 +16,7 @@ Use the fields in the *Login* dialog to authenticate your connection:
field.
* Provide your password in the *Password* field.
* Click the *Login* button to securely log into pgAdmin.
* Please note that, if the pgAdmin server is restarted then you will be logged out. You need to re-login to continue.
Recovering a Lost Password
**************************

View File

@ -0,0 +1,38 @@
.. _master_password:
************************
`Master Password`:index:
************************
A master password is required to secure and later unlock the saved server passwords. This is applicable only for desktop mode users.
* You are prompted to enter the master password when you open the window for the first time after starting the application.
* Once you set the master password, all the existing saved passwords will be re-encrypted using the master password.
* The server passwords which are saved in the SQLite DB file are encrypted and decrypted using the master password.
.. image:: images/master_password_set.png
:alt: Set master password
:align: center
* You can disable the master password by setting the configuration parameter *MASTER_PASSWORD_REQUIRED=False*
* Note that, if master password is disabled, then all the saved passwords will be removed.
.. warning:: If master password is disabled, then the saved passwords will be encrypted using a key
which may not be as secure as master password. It is strongly recommended to use master password if you use "Save password" option.
* The master password is not stored anywhere on the physical storage. It is temporarily stored in the application memory and it does not get saved in case the application gets restarted.
* You are prompted to enter the master password when pgAdmin server is restarted.
.. image:: images/master_password_enter.png
:alt: Enter master password
:align: center
* If you forget the master password, you can use the "Reset Master Password" button to reset the password.
.. image:: images/master_password_reset.png
:alt: Reset master password
:align: center
.. warning:: Resetting the master password will also remove all the saved passwords and close all the existing established
connections.

View File

@ -10,9 +10,11 @@ This release contains a number of bug fixes since the release of pgAdmin4 4.6.
Bug fixes
*********
| `Bug #3377 <https://redmine.postgresql.org/issues/3377>`_ - In server(web) mode, update all the saved server credentials when user password is changed.
| `Bug #3885 <https://redmine.postgresql.org/issues/3885>`_ - Fix the responsive layout of the main menu bar.
| `Bug #4162 <https://redmine.postgresql.org/issues/4162>`_ - Fix syntax error when adding more than one column to the existing table.
| `Bug #4164 <https://redmine.postgresql.org/issues/4164>`_ - Fix file browser path issue which occurs when client is on Windows and server is on Mac/Linux.
| `Bug #4184 <https://redmine.postgresql.org/issues/4184>`_ - Added Master Password to increase the security of saved passwords.
| `Bug #4194 <https://redmine.postgresql.org/issues/4194>`_ - Fix accessibility issue for menu navigation.
| `Bug #4208 <https://redmine.postgresql.org/issues/4208>`_ - Update the UI logo.
| `Bug #4217 <https://redmine.postgresql.org/issues/4217>`_ - Fixed CSRF security vulnerability issue.

View File

@ -419,6 +419,12 @@ SUPPORT_SSH_TUNNEL = True
# Set to False to disable password saving.
ALLOW_SAVE_TUNNEL_PASSWORD = False
##########################################################################
# Master password is used to encrypt/decrypt saved server passwords
# Applicable for desktop mode only
##########################################################################
MASTER_PASSWORD_REQUIRED = True
##########################################################################
# Local config settings
##########################################################################

View File

@ -0,0 +1,51 @@
"""empty message
Revision ID: 35f29b1701bd
Revises: ec1cac3399c9
Create Date: 2019-04-26 16:38:08.368471
"""
import base64
import os
import sys
from pgadmin.model import db, Server
# revision identifiers, used by Alembic.
revision = '35f29b1701bd'
down_revision = 'ec1cac3399c9'
branch_labels = None
depends_on = None
def upgrade():
db.engine.execute("ALTER TABLE user RENAME TO user_old")
db.engine.execute("""
CREATE TABLE user (
id INTEGER NOT NULL,
email VARCHAR(256) NOT NULL,
password VARCHAR(256),
active BOOLEAN NOT NULL,
confirmed_at DATETIME,
masterpass_check VARCHAR(256),
PRIMARY KEY (id),
UNIQUE (email),
CHECK (active IN (0, 1))
);
""")
db.engine.execute("""
INSERT INTO user (
id, email, password, active, confirmed_at
) SELECT
id, email, password, active, confirmed_at
FROM user_old""")
db.engine.execute("DROP TABLE user_old")
def downgrade():
pass

View File

@ -31,7 +31,10 @@ def upgrade():
Security(app, user_datastore, register_blueprint=False)
else:
app.config['SECURITY_PASSWORD_SALT'] = current_salt
users = User.query.all()
users = User.query.with_entities(
User.id, User.email, User.password, User.active, User.confirmed_at)\
.all()
# This will upgrade the plaintext password of all the user as per the
# SECURITY_PASSWORD_HASH.
for user in users:

View File

@ -23,15 +23,14 @@ from flask_login import user_logged_in, user_logged_out
from flask_mail import Mail
from flask_paranoid import Paranoid
from flask_security import Security, SQLAlchemyUserDatastore, current_user
from flask_security.utils import login_user
from flask_security.utils import login_user, logout_user
from werkzeug.datastructures import ImmutableDict
from werkzeug.local import LocalProxy
from werkzeug.utils import find_modules
from pgadmin.model import db, Role, Server, ServerGroup, \
User, Keys, Version, SCHEMA_VERSION as CURRENT_SCHEMA_VERSION
from pgadmin.utils import PgAdminModule, driver
from pgadmin.utils import PgAdminModule, driver, KeyManager
from pgadmin.utils.preferences import Preferences
from pgadmin.utils.session import create_session_interface, pga_unauthorised
from pgadmin.utils.versioned_template_loader import VersionedTemplateLoader
@ -575,12 +574,23 @@ def create_app(app_name=None):
def force_session_write(app, user):
session.force_write = True
@user_logged_in.connect_via(app)
def store_crypt_key(app, user):
# in desktop mode, master password is used to encrypt/decrypt
# and is stored in the keyManager memory
if config.SERVER_MODE:
if 'password' in request.form:
current_app.keyManager.set(request.form['password'])
@user_logged_out.connect_via(app)
def current_user_cleanup(app, user):
from config import PG_DEFAULT_DRIVER
from pgadmin.utils.driver import get_driver
from flask import current_app
# remove key
current_app.keyManager.reset()
for mdl in current_app.logout_hooks:
try:
mdl.on_logout(user)
@ -631,6 +641,12 @@ def create_app(app_name=None):
abort(401)
login_user(user)
# if the server is restarted the in memory key will be lost
# but the user session may still be active. Logout the user
# to get the key again when login
if config.SERVER_MODE and current_app.keyManager.get() is None:
logout_user()
@app.after_request
def after_request(response):
if 'key' in request.args:
@ -711,6 +727,9 @@ def create_app(app_name=None):
current_app.logger.error(e, exc_info=True)
return e
# Intialize the key manager
app.keyManager = KeyManager()
##########################################################################
# Protection against CSRF attacks
##########################################################################

View File

@ -18,7 +18,7 @@ from socket import error as SOCKETErrorException
import six
from flask import current_app, render_template, url_for, make_response, \
flash, Response, request, after_this_request, redirect
flash, Response, request, after_this_request, redirect, session
from flask_babelex import gettext
from flask_gravatar import Gravatar
from flask_login import current_user, login_required
@ -41,6 +41,9 @@ from pgadmin.utils.csrf import pgCSRFProtect
from pgadmin.utils.preferences import Preferences
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
try:
import urllib.request as urlreq
@ -220,7 +223,10 @@ class BrowserModule(PgAdminModule):
Returns:
list: a list of url endpoints exposed to the client.
"""
return ['browser.index', 'browser.nodes']
return ['browser.index', 'browser.nodes',
'browser.check_master_password',
'browser.set_master_password',
'browser.reset_master_password']
blueprint = BrowserModule(MODULE_NAME, __name__)
@ -682,6 +688,133 @@ def get_nodes():
return make_json_response(data=nodes)
def form_master_password_response(existing=True, present=False, errmsg=None):
content_new = (
gettext("Set Master Password"),
"<br/>".join([
gettext("Please set a master password for pgAdmin."),
gettext("This will be used to secure and later unlock saved "
"passwords and other credentials.")])
)
content_existing = (
gettext("Unlock Saved Passwords"),
"<br/>".join([
gettext("Please enter your master password."),
gettext("This is required to unlock saved passwords and "
"reconnect to the database server(s).")])
)
return make_json_response(data={
'present': present,
'title': content_existing[0] if existing else content_new[0],
'content': render_template(
'browser/master_password.html',
content_text=content_existing[1] if existing else content_new[1],
errmsg=errmsg
),
'reset': existing
})
@blueprint.route("/master_password", endpoint="check_master_password",
methods=["GET"])
def check_master_password():
"""
Checks if the master password is available in the memory
This password will be used to encrypt/decrypt saved server passwords
"""
return make_json_response(data=get_crypt_key()[0])
@blueprint.route("/master_password", endpoint="reset_master_password",
methods=["DELETE"])
def reset_master_password():
"""
Removes the master password and remove all saved passwords
This password will be used to encrypt/decrypt saved server passwords
"""
cleanup_master_password()
return make_json_response(data=get_crypt_key()[0])
@blueprint.route("/master_password", endpoint="set_master_password",
methods=["POST"])
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 hasattr(request.data, 'decode'):
data = request.data.decode('utf-8')
if data != '':
data = json.loads(data)
# Master password is not applicable for server mode
if not config.SERVER_MODE and config.MASTER_PASSWORD_REQUIRED:
if data != '' and data.get('password', '') != '':
# if master pass is set previously
if current_user.masterpass_check is not None:
if not validate_master_password(data.get('password')):
return form_master_password_response(
existing=True,
present=False,
errmsg=gettext("Incorrect master password")
)
# store the master pass in the memory
set_crypt_key(data.get('password'))
if current_user.masterpass_check is None:
# 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(
current_user.id, current_user.password,
data.get('password'))
# set the encrypted sample text with the new
# master pass
set_masterpass_check_text(data.get('password'))
elif not get_crypt_key()[0] and \
current_user.masterpass_check is not None:
return form_master_password_response(
existing=True,
present=False,
)
elif not get_crypt_key()[0]:
return form_master_password_response(
existing=False,
present=False,
)
# if master password is disabled now, but was used once then
# remove all the saved passwords
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
reencrpyt_server_passwords(
current_user.id, current_user.password, crypt_key)
set_masterpass_check_text(crypt_key)
return form_master_password_response(
present=True,
)
# Only register route if SECURITY_CHANGEABLE is set to True
# We can't access app context here so cannot
# use app.config['SECURITY_CHANGEABLE']
@ -740,6 +873,15 @@ if hasattr(config, 'SECURITY_CHANGEABLE') and config.SECURITY_CHANGEABLE:
if request.json is None and not has_error:
after_this_request(_commit)
do_flash(*get_message('PASSWORD_CHANGE'))
old_key = get_crypt_key()[1]
set_crypt_key(form.new_password.data, False)
from pgadmin.browser.server_groups.servers.utils \
import reencrpyt_server_passwords
reencrpyt_server_passwords(
current_user.id, old_key, form.new_password.data)
return redirect(get_url(_security.post_change_view) or
get_url(_security.post_login_view))

View File

@ -26,6 +26,8 @@ import config
from config import PG_DEFAULT_DRIVER
from pgadmin.model import db, Server, ServerGroup, User
from pgadmin.utils.driver import get_driver
from pgadmin.utils.master_password import get_crypt_key
from pgadmin.utils.exception import CryptKeyMissing
def has_any(data, keys):
@ -117,9 +119,16 @@ class ServerModule(sg.ServerGroupPluginModule):
driver = get_driver(PG_DEFAULT_DRIVER)
for server in servers:
connected = False
manager = None
try:
manager = driver.connection_manager(server.id)
conn = manager.connection()
connected = conn.connected()
except CryptKeyMissing:
# show the nodes at least even if not able to connect.
pass
in_recovery = None
wal_paused = None
@ -723,6 +732,11 @@ class ServerNode(PGChildNodeView):
request.data, encoding='utf-8'
)
# 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']:
required_args.extend([
@ -807,7 +821,7 @@ class ServerNode(PGChildNodeView):
# login with password
have_password = True
password = data['password']
password = encrypt(password, current_user.password)
password = encrypt(password, crypt_key)
elif 'passfile' in data and data["passfile"] != '':
passfile = data['passfile']
setattr(server, 'passfile', passfile)
@ -817,7 +831,7 @@ class ServerNode(PGChildNodeView):
have_tunnel_password = True
tunnel_password = data['tunnel_password']
tunnel_password = \
encrypt(tunnel_password, current_user.password)
encrypt(tunnel_password, crypt_key)
status, errmsg = conn.connect(
password=password,
@ -998,6 +1012,11 @@ class ServerNode(PGChildNodeView):
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
conn = manager.connection()
# 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:
@ -1014,12 +1033,12 @@ class ServerNode(PGChildNodeView):
# Encrypt the password before saving with user's login
# password key.
try:
tunnel_password = encrypt(tunnel_password, user.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=e.message)
return internal_server_error(errormsg=str(e))
if 'password' not in data:
conn_passwd = getattr(conn, 'password', None)
@ -1038,11 +1057,11 @@ class ServerNode(PGChildNodeView):
# Encrypt the password before saving with user's login
# password key.
try:
password = encrypt(password, user.password) \
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=e.message)
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
@ -1235,6 +1254,7 @@ class ServerNode(PGChildNodeView):
"""
try:
data = json.loads(request.form['data'], encoding='utf-8')
crypt_key = get_crypt_key()[1]
# Fetch Server Details
server = Server.query.filter_by(id=sid).first()
@ -1292,7 +1312,7 @@ class ServerNode(PGChildNodeView):
# Check against old password only if no pgpass file
if not is_passfile:
decrypted_password = decrypt(manager.password, user.password)
decrypted_password = decrypt(manager.password, crypt_key)
if isinstance(decrypted_password, bytes):
decrypted_password = decrypted_password.decode()
@ -1328,7 +1348,7 @@ class ServerNode(PGChildNodeView):
# Store password in sqlite only if no pgpass file
if not is_passfile:
password = encrypt(data['newPassword'], user.password)
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:
@ -1472,6 +1492,7 @@ class ServerNode(PGChildNodeView):
def get_response_for_password(self, server, status, prompt_password=False,
prompt_tunnel_password=False, errmsg=None):
if server.use_ssh_tunnel:
return make_json_response(
success=0,
@ -1498,7 +1519,7 @@ class ServerNode(PGChildNodeView):
server_label=server.name,
username=server.username,
errmsg=errmsg,
_=gettext
_=gettext,
)
)

View File

@ -166,7 +166,6 @@ class DatabaseView(PGChildNodeView):
def wrap(f):
@wraps(f)
def wrapped(self, *args, **kwargs):
self.manager = get_driver(
PG_DEFAULT_DRIVER
).connection_manager(

View File

@ -489,11 +489,15 @@ define('pgadmin.node.database', [
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);
});
},

View File

@ -1126,10 +1126,14 @@ define('pgadmin.node.server', [
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);
});
},

View File

@ -1,23 +1,33 @@
<form name="frmPassword" id="frmPassword" style="height: 100%; width: 100%" onsubmit="return false;">
<div>{% if errmsg %}
<div class="highlight has-error">
<div class='control-label'>{{ errmsg }}</div>
</div>
{% endif %}
<div>
<div><b>{{ _('Please enter the password for the user \'{0}\' to connect the server - "{1}"').format(username,
server_label) }}</b></div>
<div style="padding: 5px; height: 1px;"></div>
<div style="width: 100%">
<span style="width: 25%;display: inline-table;">Password</span>
<span style="width: 73%;display: inline-block;">
<input style="width:100%" id="password" class="form-control" name="password" type="password">
</span>
<span style="margin-left: 25%; padding-top: 15px;width: 45%;display: inline-block;">
<div class="input-group row py-2">
<label for="password" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input id="password" class="form-control" name="password" type="password">
</div>
</div>
<div class="save-password-div input-group row py-2">
<label for="password" class="col-sm-2 col-form-label"></label>
<div class="col-sm-10">
<input id="save_password" name="save_password" type="checkbox"
{% if not config.ALLOW_SAVE_PASSWORD %}disabled{% endif %}
>&nbsp;&nbsp;Save Password
</span>
>
<label for="save_password">Save Password</label>
</div>
<div style="padding: 5px; height: 1px;"></div>
</div>
{% if errmsg %}
<div class='pg-prop-status-bar p-0'>
<div class="error-in-footer">
<div class="d-flex px-2 py-1">
<div class="pr-2">
<i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i>
</div>
<div class="alert-text">{{ errmsg }}</div>
</div>
</div>
</div>
{% endif %}
</div>
</form>

View File

@ -1,42 +1,53 @@
<form name="frmPassword" id="frmPassword" style="height: 100%; width: 100%" onsubmit="return false;">
<div>{% if errmsg %}
<div class="highlight has-error">
<div class='control-label'>{{ errmsg }}</div>
</div>
{% endif %}
<form name="frmPassword" id="frmPassword" onsubmit="return false;">
<div class="m-1">
{% if prompt_tunnel_password %}
{% if tunnel_identity_file %}
<div><b>{{ _('SSH Tunnel password for the identity file \'{0}\' to connect the server "{1}"').format(tunnel_identity_file, tunnel_host) }}</b></div>
{% else %}
<div><b>{{ _('SSH Tunnel password for the user \'{0}\' to connect the server "{1}"').format(tunnel_username, tunnel_host) }}</b></div>
{% endif %}
<div style="padding: 5px; height: 1px;"></div>
<div style="width: 100%">
<span style="width: 97%;display: inline-block;">
<input style="width:100%" id="tunnel_password" class="form-control" name="tunnel_password" type="password">
</span>
<span style="padding-top: 5px;display: inline-block;">
<input id="save_tunnel_password" name="save_tunnel_password" type="checkbox"
{% if not config.ALLOW_SAVE_TUNNEL_PASSWORD %}disabled{% endif %}
>&nbsp;&nbsp;Save Password
</span>
<div class="input-group py-2">
<div class="w-100">
<input id="tunnel_password" class="form-control" name="tunnel_password" type="password">
</div>
</div>
<div class="save-password-div input-group py-2">
<div class="w-100">
<input id="save_tunnel_password" name="save_tunnel_password" type="checkbox"
{% if not config.ALLOW_SAVE_PASSWORD %}disabled{% endif %}
>
<label for="save_tunnel_password" class="ml-1">Save Password</label>
</div>
</div>
<div style="padding: 5px; height: 1px;"></div>
{% endif %}
{% if prompt_password %}
<div><b>{{ _('Database server password for the user \'{0}\' to connect the server "{1}"').format(username, server_label) }}</b></div>
<div style="padding: 5px; height: 1px;"></div>
<div style="width: 100%">
<span style="width: 97%;display: inline-block;">
<input style="width:100%" id="password" class="form-control" name="password" type="password">
</span>
<span style="padding-top: 5px;display: inline-block;">
<div class="input-group py-2">
<div class="w-100">
<input id="password" class="form-control" name="password" type="password">
</div>
</div>
<div class="save-password-div input-group py-2">
<label for="password" class="col-sm-2 col-form-label"></label>
<div class="w-100">
<input id="save_password" name="save_password" type="checkbox"
{% if not config.ALLOW_SAVE_PASSWORD %}disabled{% endif %}
>&nbsp;&nbsp;Save Password
</span>
>
<label for="save_password" class="ml-1">Save Password</label>
</div>
</div>
{% endif %}
{% if errmsg %}
<div class='pg-prop-status-bar p-0'>
<div class="error-in-footer">
<div class="d-flex px-2 py-1">
<div class="pr-2">
<i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i>
</div>
<div class="alert-text">{{ errmsg }}</div>
</div>
</div>
</div>
<div style="padding: 5px; height: 1px;"></div>
{% endif %}
</div>
</form>

View File

@ -8,6 +8,9 @@
##########################################################################
"""Server helper utilities"""
from pgadmin.utils.crypto import encrypt, decrypt
import config
from pgadmin.model import db, Server
def parse_priv_from_db(db_privileges):
@ -177,3 +180,70 @@ def validate_options(options, option_name, option_value):
is_valid_options = True
return is_valid_options, valid_options
def reencrpyt_server_passwords(user_id, old_key, new_key):
"""
This function will decrypt the saved passwords in SQLite with old key
and then encrypt with new key
"""
from pgadmin.utils.driver import get_driver
driver = get_driver(config.PG_DEFAULT_DRIVER)
for server in Server.query.filter_by(user_id=user_id).all():
manager = driver.connection_manager(server.id)
# Check if old password was stored in pgadmin4 sqlite database.
# If yes then update that password.
if server.password is not None:
password = decrypt(server.password, old_key)
if isinstance(password, bytes):
password = password.decode()
password = encrypt(password, new_key)
setattr(server, 'password', password)
manager.password = password
elif manager.password is not None:
password = decrypt(manager.password, old_key)
if isinstance(password, bytes):
password = password.decode()
password = encrypt(password, new_key)
manager.password = password
if server.tunnel_password is not None:
tunnel_password = decrypt(server.tunnel_password, old_key)
if isinstance(tunnel_password, bytes):
tunnel_password = tunnel_password.decode()
tunnel_password = encrypt(tunnel_password, new_key)
setattr(server, 'tunnel_password', tunnel_password)
manager.tunnel_password = tunnel_password
elif manager.tunnel_password is not None:
tunnel_password = decrypt(manager.tunnel_password, old_key)
if isinstance(tunnel_password, bytes):
tunnel_password = tunnel_password.decode()
tunnel_password = encrypt(tunnel_password, new_key)
manager.tunnel_password = tunnel_password
db.session.commit()
manager.update_session()
def remove_saved_passwords(user_id):
"""
This function will remove all the saved passwords for the server
"""
try:
db.session.query(Server) \
.filter(Server.user_id == user_id) \
.update({Server.password: None, Server.tunnel_password: None})
db.session.commit()
except Exception as _:
db.session.rollback()
raise

View File

@ -262,7 +262,6 @@ define('pgadmin.browser', [
});
}
});
},
menu_categories: {
/* name, label (pair) */
@ -282,6 +281,7 @@ define('pgadmin.browser', [
scripts[n] = _.isArray(scripts[n]) ? scripts[n] : [];
scripts[n].push({'name': m, 'path': p, loaded: false});
},
masterpass_callback_queue: [],
// Build the default layout
buildDefaultLayout: function(docker) {
var browserPanel = docker.addPanel('browser', wcDocker.DOCK.LEFT);
@ -542,13 +542,172 @@ define('pgadmin.browser', [
.fail(function() {});
}, 300000);
obj.set_master_password('');
obj.Events.on('pgadmin:browser:tree:add', obj.onAddTreeNode, obj);
obj.Events.on('pgadmin:browser:tree:update', obj.onUpdateTreeNode, obj);
obj.Events.on('pgadmin:browser:tree:refresh', obj.onRefreshTreeNode, obj);
obj.Events.on('pgadmin-browser:tree:loadfail', obj.onLoadFailNode, obj);
obj.bind_beforeunload();
},
init_master_password: function() {
let self = this;
// Master password dialog
if (!Alertify.dlgMasterPass) {
Alertify.dialog('dlgMasterPass', function factory() {
return {
main: function(title, message, reset) {
this.set('title', title);
this.message = message;
this.reset = reset;
},
setup:function() {
return {
buttons:[{
text: gettext('Reset Master Password'), className: 'btn btn-secondary fa fa-trash-o pg-alertify-button pull-left',
},{
text: gettext('Cancel'), className: 'btn btn-secondary fa fa-times pg-alertify-button',
key: 27,
},{
text: gettext('OK'), key: 13, className: 'btn btn-primary fa fa-check pg-alertify-button',
}],
focus: {element: '#password', select: true},
options: {
modal: true, resizable: false, maximizable: false, pinnable: false,
},
};
},
prepare:function() {
let self = this;
let $password = null;
let $okBtn = $(self.__internal.buttons[2].element);
self.setContent(self.message);
$password = $(self.elements.body).find('#password');
/* Reset button hide */
if(!self.reset) {
$(self.__internal.buttons[0].element).addClass('d-none');
} else {
$(self.__internal.buttons[0].element).removeClass('d-none');
}
/* Enable ok only if password entered */
$okBtn.prop('disabled', true);
$password.on('input', ()=>{
if($password.val() != '') {
$okBtn.prop('disabled', false);
} else {
$okBtn.prop('disabled', true);
}
});
},
callback: function(event) {
let parentDialog = this;
if (event.index == 2) {
/* OK Button */
self.set_master_password(
$('#frmMasterPassword #password').val(),
parentDialog.set_callback,
);
} else if(event.index == 1) {
/* Cancel button */
self.masterpass_callback_queue = [];
} else if(event.index == 0) {
/* Reset Button */
event.cancel = true;
Alertify.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() {
/* If user clicks Yes */
self.reset_master_password();
parentDialog.close();
return true;
},
function() {/* If user clicks No */ return true;}
).set('labels', {
ok: gettext('Yes'),
cancel: gettext('No'),
});
}
},
};
});
}
},
check_master_password: function(on_resp_callback) {
$.ajax({
url: url_for('browser.check_master_password'),
type: 'GET',
contentType: 'application/json',
}).done((res)=> {
if(on_resp_callback) {
if(res.data) {
on_resp_callback(true);
} else {
on_resp_callback(false);
}
}
}).fail(function(xhr, status, error) {
Alertify.pgRespErrorNotify(xhr, error);
});
},
reset_master_password: function() {
let self = this;
$.ajax({
url: url_for('browser.set_master_password'),
type: 'DELETE',
contentType: 'application/json',
}).done((res)=> {
if(!res.data) {
self.set_master_password('');
}
}).fail(function(xhr, status, error) {
Alertify.pgRespErrorNotify(xhr, error);
});
},
set_master_password: function(password='', set_callback=()=>{}) {
let data=null, self = this;
if(password != null || password!='') {
data = JSON.stringify({
'password': password,
});
}
self.masterpass_callback_queue.push(set_callback);
$.ajax({
url: url_for('browser.set_master_password'),
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json',
}).done((res)=> {
if(!res.data.present) {
self.init_master_password();
Alertify.dlgMasterPass(res.data.title, res.data.content, res.data.reset);
} else {
setTimeout(()=>{
while(self.masterpass_callback_queue.length > 0) {
let callback = self.masterpass_callback_queue.shift();
callback();
}
}, 500);
}
}).fail(function(xhr, status, error) {
Alertify.pgRespErrorNotify(xhr, error);
});
},
bind_beforeunload: function() {
$(window).on('beforeunload', function(e) {
/* Can open you in new tab */
@ -1619,10 +1778,13 @@ define('pgadmin.browser', [
});
}
Alertify.pgNotifier(
error, xhr, gettext('Error retrieving details for the node.'),
function() { console.warn(arguments); }
);
Alertify.pgNotifier(error, xhr, gettext('Error retrieving details for the node.'), function (msg) {
if (msg == 'CRYPTKEY_SET') {
fetchNodeInfo(_i, _d, _n);
} else {
console.warn(arguments);
}
});
}
});
}.bind(this);
@ -1665,6 +1827,21 @@ define('pgadmin.browser', [
}
},
onLoadFailNode: function(_nodeData) {
let self = this,
isSelected = self.tree.isSelected(_nodeData);
/** Check if master password set **/
self.check_master_password((is_set)=>{
if(!is_set) {
self.set_master_password('', ()=>{
if(isSelected) { self.tree.select(_nodeData); }
self.tree.open(_nodeData);
});
}
});
},
removeChildTreeNodesById: function(_parentNode, _collType, _childIds) {
var tree = pgBrowser.tree;
if(_parentNode && _collType) {

View File

@ -263,7 +263,7 @@ define([
content.find('.pg-prop-coll-container').append(that.grid.render().$el);
var timer;
var getAjaxHook = function() {
$.ajax({
url: urlBase,
type: 'GET',
@ -280,8 +280,7 @@ define([
}
}, 1000);
},
})
.done(function(res) {
}).done(function(res) {
clearTimeout(timer);
if (_.isUndefined(that.grid) || _.isNull(that.grid)) return;
@ -307,8 +306,7 @@ define([
$msgContainer.text(gettext('No properties are available for the selected object.'));
}
})
.fail(function(xhr, error) {
}).fail(function(xhr, error) {
pgBrowser.Events.trigger(
'pgadmin:node:retrieval:error', 'properties', xhr, error.message, item, that
);
@ -317,17 +315,24 @@ define([
info: info,
})) {
Alertify.pgNotifier(
error, xhr,
S(gettext('Error retrieving properties - %s')).sprintf(
error.message || that.label).value(), function() {
error, xhr, S(gettext('Error retrieving properties - %s')).sprintf(
error.message || that.label).value(),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
getAjaxHook();
} else {
console.warn(arguments);
});
}
}
);
}
// show failed message.
$msgContainer.text(gettext('Failed to retrieve data from the server.'));
});
};
getAjaxHook();
var onDrop = function(type) {
var onDrop = function(type, confirm=true) {
let sel_row_models = this.grid.getSelectedModels(),
sel_rows = [],
item = pgBrowser.tree.selected(),
@ -356,16 +361,13 @@ define([
title = gettext('DROP multiple objects?');
}
Alertify.confirm(title, msg,
function() {
let dropAjaxHook = function() {
$.ajax({
url: url,
type: 'DELETE',
data: JSON.stringify({'ids': sel_rows}),
contentType: 'application/json; charset=utf-8',
})
.done(function(res) {
}).done(function(res) {
if (res.success == 0) {
pgBrowser.report_error(res.errormsg, res.info);
} else {
@ -378,23 +380,14 @@ define([
});
}
return true;
})
.fail(function(jqx) {
var msg = jqx.responseText;
/* Error from the server */
if (jqx.status == 417 || jqx.status == 410 || jqx.status == 500) {
try {
var data = JSON.parse(jqx.responseText);
msg = data.errormsg;
} catch (e) {
console.warn(e.stack || e);
}
}
pgBrowser.report_error(
}).fail(function(xhr, error) {
Alertify.pgNotifier(
error, xhr,
S(gettext('Error dropping %s'))
.sprintf(d._label.toLowerCase())
.value(), msg);
.sprintf(d._label.toLowerCase()).value(), function(msg) {
if (msg == 'CRYPTKEY_SET') {
onDrop(type, false);
} else {
$(pgBrowser.panels['properties'].panel).removeData('node-prop');
pgBrowser.Events.trigger(
'pgadmin:browser:tree:refresh', item || pgBrowser.tree.selected(), {
@ -403,9 +396,17 @@ define([
},
}
);
}
}
);
});
},
null).show();
};
if(confirm) {
Alertify.confirm(title, msg, dropAjaxHook, null).show();
} else {
dropAjaxHook();
}
return;
}.bind(that);
},

View File

@ -404,6 +404,8 @@ define('pgadmin.browser.node', [
}
}, 1000, ctx);
var fetchAjaxHook = function() {
newModel.fetch({
success: function() {
// Clear timeout and remove message
@ -416,27 +418,32 @@ define('pgadmin.browser.node', [
setFocusOnEl();
newModel.startNewSession();
},
error: function(xhr, error, message) {
error: function(model, xhr, options) {
var _label = that && item ?
that.getTreeNodeHierarchy(
item
)[that.type].label : '';
pgBrowser.Events.trigger(
'pgadmin:node:retrieval:error', 'properties',
xhr, error, message, item
xhr, options.textStatus, options.errorThrown, item
);
if (!Alertify.pgHandleItemError(
xhr, error, message, {
xhr, options.textStatus, options.errorThrown, {
item: item,
info: info,
}
)) {
Alertify.pgNotifier(
error, xhr,
options.textStatus, xhr,
S(
gettext('Error retrieving properties - %s')
).sprintf(message || _label).value(),
function() {}
).sprintf(options.errorThrown || _label).value(), function(msg) {
if(msg === 'CRYPTKEY_SET') {
fetchAjaxHook();
} else {
console.warn(arguments);
}
}
);
}
// Close the panel (if could not fetch properties)
@ -445,9 +452,11 @@ define('pgadmin.browser.node', [
}
},
});
};
fetchAjaxHook();
} else {
// Yay - render the view now!
// $(el).focus();
view.render();
setFocusOnEl();
newModel.startNewSession();

View File

@ -0,0 +1,23 @@
<form name="frmMasterPassword" id="frmMasterPassword" style="height: 100%; width: 100%" onsubmit="return false;">
<div>
<div><b>{{ content_text|safe }}</b></div>
<div class="input-group row py-2">
<label for="password" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="password" name="password">
</div>
</div>
{% if errmsg %}
<div class='pg-prop-status-bar p-0'>
<div class="error-in-footer">
<div class="d-flex px-2 py-1">
<div class="pr-2">
<i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i>
</div>
<div class="alert-text">{{ errmsg }}</div>
</div>
</div>
</div>
{% endif %}
</div>
</form>

View File

@ -0,0 +1,106 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2019, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
import config
class MasterPasswordTestCase(BaseTestGenerator):
"""
This class validates the change password functionality
by defining change password scenarios; where dict of
parameters describes the scenario appended by test name.
"""
scenarios = [
# This testcase validates invalid confirmation password
('TestCase for Create master password dialog', dict(
password="",
content=(
"Set Master Password",
[
"Please set a master password for pgAdmin.",
"This will be used to secure and later unlock saved "
"passwords and other credentials."
]
)
)),
('TestCase for Setting Master Password', dict(
password="masterpasstest",
check_if_set=True,
)),
('TestCase for Resetting Master Password', dict(
reset=True,
password="",
content=(
"Set Master Password",
[
"Please set a master password for pgAdmin.",
"This will be used to secure and later unlock saved "
"passwords and other credentials."
]
)
)),
]
def setUp(self):
config.MASTER_PASSWORD_REQUIRED = True
def runTest(self):
"""This function will check change password functionality."""
req_data = dict()
if hasattr(self, 'password'):
req_data['password'] = self.password
if hasattr(self, 'restart'):
req_data['restart'] = self.restart
if hasattr(self, 'reset'):
req_data['reset'] = self.reset
if config.SERVER_MODE:
response = self.tester.post(
'/browser/master_password',
data=json.dumps(req_data),
)
self.assertEquals(response.json['data']['present'], True)
else:
if 'reset' in req_data:
response = self.tester.delete(
'/browser/master_password'
)
self.assertEquals(response.status_code, 200)
self.assertEquals(response.json['data'], False)
else:
response = self.tester.post(
'/browser/master_password',
data=json.dumps(req_data),
)
self.assertEquals(response.status_code, 200)
if hasattr(self, 'content'):
self.assertEquals(response.json['data']['title'],
self.content[0])
for text in self.content[1]:
self.assertIn(text, response.json['data']['content'])
if hasattr(self, 'check_if_set'):
response = self.tester.get(
'/browser/master_password'
)
self.assertEquals(response.status_code, 200)
self.assertEquals(response.json['data'], True)
def tearDown(self):
config.MASTER_PASSWORD_REQUIRED = False

View File

@ -18,6 +18,8 @@ from flask_babelex import gettext
from config import PG_DEFAULT_DRIVER
from pgadmin.utils.ajax import make_json_response, precondition_required
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\
CryptKeyMissing
def is_version_in_range(sversion, min_ver, max_ver):
@ -321,9 +323,19 @@ class PGChildNodeView(NodeView):
if 'did' in kwargs:
did = kwargs['did']
try:
conn = manager.connection(did=did)
if not conn.connected():
status, msg = conn.connect()
if not status:
return precondition_required(
gettext(
"Connection to the server has been lost."
)
)
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
except Exception as e:
return precondition_required(
gettext(
"Connection to the server has been lost."

View File

@ -223,6 +223,7 @@ define('pgadmin.dashboard', [
var div = dashboardPanel.layout().scene().find('.pg-panel-content');
if (div) {
var ajaxHook = function() {
$.ajax({
url: url,
type: 'GET',
@ -231,11 +232,26 @@ define('pgadmin.dashboard', [
.done(function(data) {
$(div).html(data);
})
.fail(function() {
.fail(function(xhr, error) {
Alertify.pgNotifier(
error, xhr,
gettext('An error occurred whilst loading the dashboard.'),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
ajaxHook();
} else {
$(div).html(
'<div class="alert alert-danger pg-panel-message" role="alert">' + gettext('An error occurred whilst loading the dashboard.') + '</div>'
);
}
}
);
});
};
$(div).html(
'<div class="alert alert-info pg-panel-message" role="alert">' + gettext('Loading dashboard...') + '</div>'
);
ajaxHook();
// Cache the current IDs for next time
$(dashboardPanel).data('sid', -1);
@ -337,6 +353,7 @@ define('pgadmin.dashboard', [
/* Clear all the charts previous dashboards */
self.clearChartFromStore();
let ajaxHook = function() {
$.ajax({
url: url,
type: 'GET',
@ -346,11 +363,26 @@ define('pgadmin.dashboard', [
$(div).html(data);
self.init_dashboard();
})
.fail(function() {
.fail(function(xhr, error) {
Alertify.pgNotifier(
error, xhr,
gettext('An error occurred whilst loading the dashboard.'),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
ajaxHook();
} else {
$(div).html(
'<div class="alert alert-danger pg-panel-message" role="alert">' + gettext('An error occurred whilst loading the dashboard.') + '</div>'
);
}
}
);
});
};
$(div).html(
'<div class="alert alert-info pg-panel-message" role="alert">' + gettext('Loading dashboard...') + '</div>'
);
ajaxHook();
$(dashboardPanel).data('server_status', true);
}
} else {

View File

@ -215,8 +215,13 @@ define('misc.dependencies', [
Alertify.pgNotifier(
error, xhr,
S(gettext('Error retrieving data from the server: %s')).sprintf(
message || _label).value(), function() {
message || _label).value(),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
self.showDependencies(item, data, node);
} else {
console.warn(arguments);
}
});
}
// show failed message.

View File

@ -221,8 +221,13 @@ define('misc.dependents', [
Alertify.pgNotifier(
error, xhr,
S(gettext('Error retrieving data from the server: %s')).sprintf(
message || _label).value(), function() {
message || _label).value(),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
self.showDependents(item, data, node);
} else {
console.warn(arguments);
}
});
}
// show failed message.

View File

@ -119,7 +119,7 @@ define('misc.sql', [
sql = '';
var timer;
var ajaxHook = function() {
$.ajax({
url: url,
type: 'GET',
@ -130,20 +130,17 @@ define('misc.sql', [
// Generate a timer for the request
timer = setTimeout(function() {
// Notify user if request is taking longer than 1 second
pgAdmin.Browser.editor.setValue(
gettext('Retrieving data from the server...')
);
}, 1000);
},
})
.done(function(res) {
}).done(function(res) {
if (pgAdmin.Browser.editor.getValue() != res) {
pgAdmin.Browser.editor.setValue(res);
}
clearTimeout(timer);
})
.fail(function(xhr, error, message) {
}).fail(function(xhr, error, message) {
var _label = treeHierarchy[n_type].label;
pgBrowser.Events.trigger(
'pgadmin:node:retrieval:error', 'sql', xhr, error, message, item
@ -156,11 +153,18 @@ define('misc.sql', [
error, xhr,
S(gettext('Error retrieving the information - %s')).sprintf(
message || _label
).value(),
function() {}
).value(), function(msg) {
if(msg === 'CRYPTKEY_SET') {
ajaxHook();
} else {
console.warn(arguments);
}
}
);
}
});
};
ajaxHook();
}
}

View File

@ -232,6 +232,7 @@ define('misc.statistics', [
msg = '';
var timer;
// Set the url, fetch the data and update the collection
var ajaxHook = function() {
$.ajax({
url: url,
type: 'GET',
@ -316,13 +317,21 @@ define('misc.statistics', [
error, xhr,
S(gettext('Error retrieving the information - %s')).sprintf(
message || _label
).value(),
function() {}
).value(), function(msg) {
if(msg === 'CRYPTKEY_SET') {
ajaxHook();
} else {
console.warn(arguments);
}
}
);
}
// show failed message.
$msgContainer.text(gettext('Failed to retrieve data from the server.'));
});
};
ajaxHook();
}
}
if (msg != '') {

View File

@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
#
##########################################################################
SCHEMA_VERSION = 22
SCHEMA_VERSION = 23
##########################################################################
#
@ -70,6 +70,7 @@ class User(db.Model, UserMixin):
password = db.Column(db.String(256))
active = db.Column(db.Boolean(), nullable=False)
confirmed_at = db.Column(db.DateTime())
masterpass_check = db.Column(db.String(256))
roles = db.relationship('Role', secondary=roles_users,
backref=db.backref('users', lazy='dynamic'))

View File

@ -107,7 +107,15 @@ define([
if (contentType.indexOf('application/json') == 0) {
var resp = JSON.parse(msg);
if (resp.result != null && (!resp.errormsg || resp.errormsg == '') &&
if(resp.info == 'CRYPTKEY_MISSING') {
var pgBrowser = window.pgAdmin.Browser;
pgBrowser.set_master_password('', ()=> {
if(onJSONResult && typeof(onJSONResult) == 'function') {
onJSONResult('CRYPTKEY_SET');
}
});
return;
} else if (resp.result != null && (!resp.errormsg || resp.errormsg == '') &&
onJSONResult && typeof(onJSONResult) == 'function') {
return onJSONResult(resp.result);
}
@ -375,6 +383,11 @@ define([
reconnectServer();
});
return true;
} else if (jsonResp && jsonResp.info == 'CRYPTKEY_MISSING' && xhr.status == 503) {
/* Suppress the error here and handle in Alertify.pgNotifier wherever
* required, as it has callback option
*/
return false;
}
return false;
};

View File

@ -230,6 +230,12 @@ class ExecuteQuery {
this.sqlServerObject.handle_connection_lost(false, httpMessage);
}
if(this.isCryptKeyMissing(httpMessage)) {
this.sqlServerObject.saveState('execute', [this.explainPlan]);
this.sqlServerObject.handle_cryptkey_missing();
return;
}
let msg = httpMessage.response.data.errormsg;
this.sqlServerObject.update_msg_history(false, msg);
}
@ -240,6 +246,12 @@ class ExecuteQuery {
httpMessage.response.data.info === 'CONNECTION_LOST';
}
isCryptKeyMissing(httpMessage) {
return httpMessage.response.status === 503 &&
httpMessage.response.data.info !== undefined &&
httpMessage.response.data.info === 'CRYPTKEY_MISSING';
}
removeGridViewMarker() {
if (this.sqlServerObject.gridView.marker) {
this.sqlServerObject.gridView.marker.clear();

View File

@ -607,6 +607,9 @@ define([
*/
$('.wizard-progress-bar p').show();
var fetchAjaxHook = function() {
$('.wizard-progress-bar p').removeClass('alert-danger').addClass('alert-info');
$('.wizard-progress-bar p').text(gettext('Please wait while fetching records...'));
coll.fetch({
success: function(c, xhr) {
$('.wizard-progress-bar p').html('');
@ -618,21 +621,28 @@ define([
$('.pg-prop-status-bar').css('visibility', 'visible');
}
},
error: function(m, xhr) {
error: function(model, xhr, options) {
// If the main request fails as whole then
let msg;
if (xhr && xhr.responseJSON && xhr.responseJSON.errormsg) {
msg = xhr.responseJSON.errormsg;
}
$('.wizard-progress-bar p').removeClass('alert-info').addClass('alert-danger');
$('.wizard-progress-bar p').text(gettext('Unable to fetch the database objects'));
if(!msg) {
msg = gettext('Unable to fetch the database objects due to an error');
}
Alertify.pgNotifier(
options.textStatus, xhr,
gettext('Unable to fetch the database objects'),
function(msg) {
if(msg === 'CRYPTKEY_SET') {
fetchAjaxHook();
} else {
$('.wizard-progress-bar p').removeClass('alert-info').addClass('alert-danger');
$('.wizard-progress-bar p').text(msg);
}
}
);
},
reset: true,
}, this);
};
fetchAjaxHook();
//////////////////////////////////////////////////////////////////////
// //

View File

@ -8,10 +8,8 @@
##########################################################################
"""A blueprint module implementing the sqleditor frame."""
import codecs
import os
import pickle
import random
import sys
import simplejson as json
@ -32,10 +30,11 @@ from pgadmin.tools.sqleditor.utils.update_session_grid_transaction import \
from pgadmin.utils import PgAdminModule
from pgadmin.utils import get_storage_directory
from pgadmin.utils.ajax import make_json_response, bad_request, \
success_return, internal_server_error, unauthorized
success_return, internal_server_error
from pgadmin.utils.driver import get_driver
from pgadmin.utils.menu import MenuItem
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\
CryptKeyMissing
from pgadmin.utils.sqlautocomplete.autocomplete import SQLAutoComplete
from pgadmin.tools.sqleditor.utils.query_tool_preferences import \
RegisterQueryToolPreferences
@ -176,7 +175,7 @@ def check_transaction_status(trans_id):
use_binary_placeholder=True,
array_to_string=True
)
except (ConnectionLost, SSHTunnelConnectionLost) as e:
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
except Exception as e:
current_app.logger.error(e)

View File

@ -2008,6 +2008,11 @@ define('tools.querytool', [
this.warn_before_continue();
}
},
handle_cryptkey_missing: function() {
pgBrowser.set_master_password('', ()=>{
this.warn_before_continue();
});
},
warn_before_continue: function() {
var self = this;

View File

@ -25,7 +25,8 @@ from pgadmin.tools.sqleditor.utils.update_session_grid_transaction import \
update_session_grid_transaction
from pgadmin.utils.ajax import make_json_response, internal_server_error
from pgadmin.utils.driver import get_driver
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\
CryptKeyMissing
class StartRunningQuery:
@ -63,7 +64,7 @@ class StartRunningQuery:
auto_reconnect=False,
use_binary_placeholder=True,
array_to_string=True)
except (ConnectionLost, SSHTunnelConnectionLost):
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
except Exception as e:
self.logger.error(e)
@ -134,7 +135,7 @@ class StartRunningQuery:
# and formatted_error is True.
try:
status, result = conn.execute_async(sql)
except (ConnectionLost, SSHTunnelConnectionLost):
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
# If the transaction aborted for some reason and

View File

@ -14,6 +14,8 @@ from operator import attrgetter
from flask import Blueprint, current_app
from flask_babelex import gettext
from flask_security import current_user, login_required
from threading import Lock
from .paths import get_storage_directory
from .preferences import Preferences
@ -330,3 +332,46 @@ SHORTCUT_FIELDS = [
'label': gettext('Alt/Option')
}
]
class KeyManager:
def __init__(self):
self.users = dict()
self.lock = Lock()
@login_required
def get(self):
user = self.users.get(current_user.id, None)
if user is not None:
return user.get('key', None)
@login_required
def set(self, _key, _new_login=True):
with self.lock:
user = self.users.get(current_user.id, None)
if user is None:
self.users[current_user.id] = dict(
session_count=1, key=_key)
else:
if _new_login:
user['session_count'] += 1
user['key'] = _key
@login_required
def reset(self):
with self.lock:
user = self.users.get(current_user.id, None)
if user is not None:
# This will not decrement if session expired
user['session_count'] -= 1
if user['session_count'] == 0:
del self.users[current_user.id]
@login_required
def hard_reset(self):
with self.lock:
user = self.users.get(current_user.id, None)
if user is not None:
del self.users[current_user.id]

View File

@ -14,7 +14,8 @@ object.
"""
import datetime
from flask import session
from flask import session, request
from flask_login import current_user
from flask_babelex import gettext
import psycopg2
from psycopg2.extensions import adapt
@ -74,23 +75,25 @@ class Driver(BaseDriver):
assert (sid is not None and isinstance(sid, int))
managers = None
server_data = Server.query.filter_by(id=sid).first()
if server_data is None:
return None
if session.sid not in self.managers:
self.managers[session.sid] = managers = dict()
if '__pgsql_server_managers' in session:
session_managers = session['__pgsql_server_managers'].copy()
session['__pgsql_server_managers'] = dict()
for server_id in session_managers:
s = Server.query.filter_by(id=server_id).first()
if not s:
continue
manager = managers[str(server_id)] = ServerManager(s)
manager._restore(session_managers[server_id])
manager = managers[str(sid)] = ServerManager(server_data)
if sid in session_managers:
manager._restore(session_managers[sid])
manager.update_session()
else:
managers = self.managers[session.sid]
if str(sid) in managers:
manager = managers[str(sid)]
manager._restore_connections()
manager.update_session()
managers['pinged'] = datetime.datetime.now()
if str(sid) not in managers:

View File

@ -28,16 +28,17 @@ from pgadmin.utils.crypto import decrypt
from psycopg2.extensions import adapt, encodings
import config
from pgadmin.model import Server, User
from pgadmin.utils.exception import ConnectionLost
from pgadmin.model import User
from pgadmin.utils.exception import ConnectionLost, CryptKeyMissing
from pgadmin.utils import get_complete_file_path
from ..abstract import BaseDriver, BaseConnection
from ..abstract import BaseConnection
from .cursor import DictCursor
from .typecast import register_global_typecasters, \
register_string_typecasters, register_binary_typecasters, \
register_array_to_string_typecasters, ALL_JSON_TYPES
from .encoding import getEncoding, configureDriverEncodings
from pgadmin.utils import csv
from pgadmin.utils.master_password import get_crypt_key
if sys.version_info < (3,):
from StringIO import StringIO
@ -242,10 +243,16 @@ class Connection(BaseConnection):
if encpass is None:
encpass = self.password or getattr(manager, 'password', None)
self.password = encpass
# Reset the existing connection password
if self.reconnecting is not False:
self.password = None
crypt_key_present, crypt_key = get_crypt_key()
if not crypt_key_present:
raise CryptKeyMissing()
if encpass:
# Fetch Logged in User Details.
user = User.query.filter_by(id=current_user.id).first()
@ -254,14 +261,13 @@ class Connection(BaseConnection):
return False, gettext("Unauthorized request.")
try:
password = decrypt(encpass, user.password)
password = decrypt(encpass, crypt_key)
# Handling of non ascii password (Python2)
if hasattr(str, 'decode'):
password = password.decode('utf-8').encode('utf-8')
# password is in bytes, for python3 we need it in string
elif isinstance(password, bytes):
password = password.decode()
except Exception as e:
manager.stop_ssh_tunnel()
current_app.logger.exception(e)
@ -521,6 +527,9 @@ WHERE
def __cursor(self, server_cursor=False):
if not get_crypt_key()[0]:
raise CryptKeyMissing()
# Check SSH Tunnel is alive or not. If used by the database
# server for the connection.
if self.manager.use_ssh_tunnel == 1:
@ -1081,7 +1090,7 @@ WHERE
current_app.logger.exception(e)
self.reconnecting = False
current_app.warning(
current_app.logger.warning(
"Failed to reconnect the database server "
"(#{server_id})".format(
server_id=self.manager.sid,
@ -1283,7 +1292,11 @@ WHERE
if user is None:
return False, gettext("Unauthorized request.")
password = decrypt(password, user.password).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:
pg_conn = psycopg2.connect(
@ -1567,7 +1580,12 @@ Failed to reset the connection to the server due to following error:
if user is None:
return False, gettext("Unauthorized request.")
password = decrypt(password, user.password).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:
pg_conn = psycopg2.connect(

View File

@ -19,13 +19,19 @@ from flask_babelex import gettext
from pgadmin.utils import get_complete_file_path
from pgadmin.utils.crypto import decrypt
from pgadmin.utils.master_password import process_masterpass_disabled
from .connection import Connection
from pgadmin.model import Server, User
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost
from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\
CryptKeyMissing
from pgadmin.utils.master_password import get_crypt_key
from threading import Lock
if config.SUPPORT_SSH_TUNNEL:
from sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError
connection_restore_lock = Lock()
class ServerManager(object):
"""
@ -185,6 +191,10 @@ class ServerManager(object):
maintenance_db_id = u'DB:{0}'.format(self.db)
if maintenance_db_id in self.connections:
conn = self.connections[maintenance_db_id]
# try to connect maintenance db if not connected
if not conn.connected():
conn.connect()
if conn.connected():
status, res = conn.execute_dict(u"""
SELECT
@ -205,6 +215,10 @@ WHERE db.oid = {0}""".format(did))
"Could not find the specified database."
))
if not get_crypt_key()[0]:
# the reason its not connected might be missing key
raise CryptKeyMissing()
if database is None:
# Check SSH Tunnel is alive or not.
if self.use_ssh_tunnel == 1:
@ -239,6 +253,15 @@ WHERE db.oid = {0}""".format(did))
"""
# restore server version from flask session if flask server was
# restarted. As we need server version to resolve sql template paths.
masterpass_processed = process_masterpass_disabled()
# The data variable is a copy so is not automatically synced
# update here
if masterpass_processed and 'password' in data:
data['password'] = None
if masterpass_processed and 'tunnel_password' in data:
data['tunnel_password'] = None
from pgadmin.browser.server_groups.servers.types import ServerType
self.ver = data.get('ver', None)
@ -251,16 +274,12 @@ WHERE db.oid = {0}""".format(did))
self.server_cls = st
break
# Hmm.. we will not honour this request, when I already have
# connections
if len(self.connections) != 0:
return
# We need to know about the existing server variant supports during
# first connection for identifications.
self.pinged = datetime.datetime.now()
try:
if 'password' in data and data['password']:
if hasattr(data['password'], 'encode'):
data['password'] = data['password'].encode('utf-8')
if 'tunnel_password' in data and data['tunnel_password']:
data['tunnel_password'] = \
@ -269,21 +288,28 @@ WHERE db.oid = {0}""".format(did))
current_app.logger.exception(e)
connections = data['connections']
with connection_restore_lock:
for conn_id in connections:
conn_info = connections[conn_id]
if conn_info['conn_id'] in self.connections:
conn = self.connections[conn_info['conn_id']]
else:
conn = self.connections[conn_info['conn_id']] = Connection(
self, conn_info['conn_id'], conn_info['database'],
conn_info['auto_reconnect'], conn_info['async_'],
use_binary_placeholder=conn_info['use_binary_placeholder'],
use_binary_placeholder=conn_info[
'use_binary_placeholder'],
array_to_string=conn_info['array_to_string']
)
# only try to reconnect if connection was connected previously and
# auto_reconnect is true.
# only try to reconnect if connection was connected previously
# and auto_reconnect is true.
if conn_info['wasConnected'] and conn_info['auto_reconnect']:
try:
# Check SSH Tunnel needs to be created
if self.use_ssh_tunnel == 1 and not self.tunnel_created:
if self.use_ssh_tunnel == 1 and \
not self.tunnel_created:
status, error = self.create_ssh_tunnel(
data['tunnel_password'])
@ -294,11 +320,47 @@ WHERE db.oid = {0}""".format(did))
password=data['password'],
server_types=ServerType.types()
)
# This will also update wasConnected flag in connection so
# no need to update the flag manually.
# This will also update wasConnected flag in
# connection so no need to update the flag manually.
except CryptKeyMissing:
# maintain the status as this will help to restore once
# the key is available
conn.wasConnected = conn_info['wasConnected']
conn.auto_reconnect = conn_info['auto_reconnect']
except Exception as e:
current_app.logger.exception(e)
self.connections.pop(conn_info['conn_id'])
raise
def _restore_connections(self):
with connection_restore_lock:
for conn_id in self.connections:
conn = self.connections[conn_id]
# only try to reconnect if connection was connected previously
# and auto_reconnect is true.
wasConnected = conn.wasConnected
auto_reconnect = conn.auto_reconnect
if conn.wasConnected and conn.auto_reconnect:
try:
# Check SSH Tunnel needs to be created
if self.use_ssh_tunnel == 1 and \
not self.tunnel_created:
status, error = self.create_ssh_tunnel()
# Check SSH Tunnel is alive or not.
self.check_ssh_tunnel_alive()
conn.connect()
# This will also update wasConnected flag in
# connection so no need to update the flag manually.
except CryptKeyMissing:
# maintain the status as this will help to restore once
# the key is available
conn.wasConnected = wasConnected
conn.auto_reconnect = auto_reconnect
except Exception as e:
current_app.logger.exception(e)
raise
def release(self, database=None, conn_id=None, did=None):
# Stop the SSH tunnel if release() function calls without

View File

@ -80,3 +80,28 @@ class SSHTunnelConnectionLost(HTTPException):
def __repr__(self):
return "Connection to the SSH Tunnel for host '{0}' has been lost. " \
"Reconnect to the database server".format(self.tunnel_host)
class CryptKeyMissing(HTTPException):
"""
Exception
"""
def __init__(self):
HTTPException.__init__(self)
@property
def name(self):
return HTTP_STATUS_CODES.get(503, 'Service Unavailable')
def get_response(self, environ=None):
return service_unavailable(
_("Crypt key is missing."),
info="CRYPTKEY_MISSING",
)
def __str__(self):
return "Crypt key is missing."
def __repr__(self):
return "Crypt key is missing."

View File

@ -0,0 +1,119 @@
import config
from flask import current_app
from flask_login import current_user
from pgadmin.model import db, User, Server
from pgadmin.utils.crypto import encrypt, decrypt
MASTERPASS_CHECK_TEXT = 'ideas are bulletproof'
def set_crypt_key(_key, _new_login=True):
"""
Set the crypt key
:param _key: The key
:param _new_login: Is fresh login or password change
"""
current_app.keyManager.set(_key, _new_login)
def get_crypt_key():
"""
Returns the 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:
return True, current_user.password
# if desktop mode and master pass enabled
elif config.MASTER_PASSWORD_REQUIRED \
and not config.SERVER_MODE and enc_key is None:
return False, None
else:
return True, enc_key
def validate_master_password(password):
"""
Validate the password/key against the stored encrypted text
:param password: password/key
:return: Valid or not
"""
# master pass is incorrect if decryption fails
try:
decrypted_text = decrypt(current_user.masterpass_check, password)
if isinstance(decrypted_text, bytes):
decrypted_text = decrypted_text.decode()
if MASTERPASS_CHECK_TEXT != decrypted_text:
return False
else:
return True
except Exception as _:
False
def set_masterpass_check_text(password, clear=False):
"""
Set the encrypted text which will be used later to validate entered key
:param password: password/key
:param clear: remove the encrypted text
"""
try:
masterpass_check = None
if not clear:
masterpass_check = encrypt(MASTERPASS_CHECK_TEXT, password)
# set the encrypted sample text with the new
# master pass
db.session.query(User) \
.filter(User.id == current_user.id) \
.update({User.masterpass_check: masterpass_check})
db.session.commit()
except Exception as _:
db.session.rollback()
raise
def cleanup_master_password():
"""
Remove the master password and saved passwords from DB which are
encrypted using master password. Also remove the encrypted text
"""
# also remove the master password check string as it will help if master
# password entered/enabled again
set_masterpass_check_text('', clear=True)
from pgadmin.browser.server_groups.servers.utils \
import remove_saved_passwords
remove_saved_passwords(current_user.id)
current_app.keyManager.hard_reset()
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)
def process_masterpass_disabled():
"""
On master password disable, remove the connection data from session as it
may have saved password which will cause trouble
:param session: Flask session
:param conn_data: connection manager copy from session if any
"""
if not config.SERVER_MODE and not config.MASTER_PASSWORD_REQUIRED \
and current_user.masterpass_check is not None:
cleanup_master_password()
return True
return False

View File

@ -59,6 +59,8 @@ if config.SERVER_MODE is True:
config.SECURITY_CHANGEABLE = True
config.SECURITY_POST_CHANGE_VIEW = 'browser.change_password'
# disable master password for test cases
config.MASTER_PASSWORD_REQUIRED = False
from regression import test_setup
from regression.feature_utils.app_starter import AppStarter
@ -187,7 +189,14 @@ def get_test_modules(arguments):
global driver, app_starter, handle_cleanup
if not config.SERVER_MODE:
exclude_pkgs.append("browser.tests")
# following test cases applicable only for server mode
exclude_pkgs.extend([
"browser.tests.test_change_password",
"browser.tests.test_gravatar_image_display",
"browser.tests.test_login",
"browser.tests.test_logout",
"browser.tests.test_reset_password",
])
if arguments['exclude'] is not None:
exclude_pkgs += arguments['exclude'].split(',')