diff --git a/web/config.py b/web/config.py index f24373e4a..7d2f02ee0 100644 --- a/web/config.py +++ b/web/config.py @@ -447,6 +447,22 @@ SESSION_EXPIRATION_TIME = 1 # the session files for cleanup after specified number of *hours*. CHECK_SESSION_FILES_INTERVAL = 24 +# USER_INACTIVITY_TIMEOUT is interval in Seconds. If the pgAdmin screen is left +# unattended for seconds then the user will +# be logged out. When set to 0, the timeout will be disabled. +# If pgAdmin doesn't detect any activity in the time specified (in seconds), +# the user will be forcibly logged out from pgAdmin. Set to zero to disable +# the timeout. +# Note: This is applicable only for SERVER_MODE=True. +USER_INACTIVITY_TIMEOUT = 0 + +# OVERRIDE_USER_INACTIVITY_TIMEOUT when set to True will override +# USER_INACTIVITY_TIMEOUT when long running queries in the Query Tool +# or Debugger are running. When the queries complete, the inactivity timer +# will restart in this case. If set to False, user inactivity may cause +# transactions or in-process debugging sessions to be aborted. +OVERRIDE_USER_INACTIVITY_TIMEOUT = True + ########################################################################## # SSH Tunneling supports only for Python 2.7 and 3.4+ ########################################################################## @@ -495,3 +511,7 @@ if (SUPPORT_SSH_TUNNEL is True and (sys.version_info[0] == 3 and sys.version_info[1] < 4))): SUPPORT_SSH_TUNNEL = False ALLOW_SAVE_TUNNEL_PASSWORD = False + +# Disable USER_INACTIVITY_TIMEOUT when SERVER_MODE=False +if not SERVER_MODE: + USER_INACTIVITY_TIMEOUT = 0 diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 5aee341fd..3a54aafe4 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -520,6 +520,11 @@ class BrowserPluginModule(PgAdminModule): ) +def _get_logout_url(): + return '{0}?next={1}'.format( + url_for('security.logout'), url_for('browser.index')) + + @blueprint.route("/") @pgCSRFProtect.exempt @login_required @@ -579,6 +584,7 @@ def index(): MODULE_NAME + "/index.html", username=current_user.email, is_admin=current_user.has_role("Administrator"), + logout_url=_get_logout_url(), _=gettext )) @@ -666,7 +672,8 @@ def utils(): editor_indent_with_tabs=editor_indent_with_tabs, app_name=config.APP_NAME, pg_libpq_version=pg_libpq_version, - support_ssh_tunnel=config.SUPPORT_SSH_TUNNEL + support_ssh_tunnel=config.SUPPORT_SSH_TUNNEL, + logout_url=_get_logout_url() ), 200, {'Content-Type': 'application/javascript'}) diff --git a/web/pgadmin/browser/static/js/activity.js b/web/pgadmin/browser/static/js/activity.js new file mode 100644 index 000000000..f2fcdbb95 --- /dev/null +++ b/web/pgadmin/browser/static/js/activity.js @@ -0,0 +1,114 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2020, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import $ from 'jquery'; +import _ from 'underscore'; + +import pgAdmin from 'sources/pgadmin'; +import pgWindow from 'sources/window'; +import {getEpoch} from 'sources/utils'; + +const pgBrowser = pgAdmin.Browser = pgAdmin.Browser || {}; +const MIN_ACTIVITY_TIME_UNIT = 1000; /* in seconds */ +/* + * User UI activity related functions. + */ +_.extend(pgBrowser, { + inactivity_timeout_at: null, + logging_activity: false, + inactivity_timeout_daemon_running: false, + + is_pgadmin_timedout: function() { + return !pgWindow.pgAdmin; + }, + + is_inactivity_timeout: function() { + return pgWindow.pgAdmin.Browser.inactivity_timeout_at < this.get_epoch_now(); + }, + + get_epoch_now: function(){ + return getEpoch(); + }, + + log_activity: function() { + if(!this.logging_activity) { + this.logging_activity = true; + this.inactivity_timeout_at = this.get_epoch_now() + pgAdmin.user_inactivity_timeout; + + /* No need to log events till next MIN_ACTIVITY_TIME_UNIT second as the + * value of inactivity_timeout_at won't change + */ + setTimeout(()=>{ + this.logging_activity = false; + }, MIN_ACTIVITY_TIME_UNIT); + } + }, + + /* Call this to register element for acitivity monitoring + * Generally, document is passed to cover all. + */ + register_to_activity_listener: function(target, timeout_callback) { + let inactivity_events = ['mousemove', 'mousedown', 'keydown']; + let self = this; + inactivity_events.forEach((event)=>{ + /* Bind events in the event capture phase, the bubble phase might stop propagation */ + let eventHandler = function() { + if(self.is_pgadmin_timedout()) { + /* If the main page has logged out then remove the listener and call the timeout callback */ + inactivity_events.forEach((event)=>{ + target.removeEventListener(event, eventHandler, true); + }); + timeout_callback(); + } else { + pgWindow.pgAdmin.Browser.log_activity(); + } + }; + + target.addEventListener(event, eventHandler, true); + }); + }, + + /* + * This function can be used by tools like sqleditor where + * poll call is as good as user activity. Decorate such functions + * with this to consider them as events. Note that, this is controlled + * by override_user_inactivity_timeout. + */ + override_activity_event_decorator: function(input_func) { + return function() { + /* Log only if override_user_inactivity_timeout true */ + if(pgAdmin.override_user_inactivity_timeout) { + pgWindow.pgAdmin.Browser.log_activity(); + } + return input_func.apply(this, arguments); + }; + }, + + logout_inactivity_user: function() { + window.location.href = pgBrowser.utils.logout_url; + }, + + /* The daemon will track and logout when timeout occurs */ + start_inactivity_timeout_daemon: function() { + let self = this; + if(pgAdmin.user_inactivity_timeout > 0 && !self.inactivity_timeout_daemon_running) { + let timeout_daemon_id = setInterval(()=>{ + self.inactivity_timeout_daemon_running = true; + if(self.is_inactivity_timeout()) { + clearInterval(timeout_daemon_id); + self.inactivity_timeout_daemon_running = false; + $(window).off('beforeunload'); + self.logout_inactivity_user(); + } + }, MIN_ACTIVITY_TIME_UNIT); + } + }, +}); + +export {pgBrowser}; diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 2ef881b9a..1941ef9a4 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -17,7 +17,7 @@ define('pgadmin.browser', [ 'pgadmin.browser.preferences', 'pgadmin.browser.messages', 'pgadmin.browser.menu', 'pgadmin.browser.panel', 'pgadmin.browser.layout', 'pgadmin.browser.error', 'pgadmin.browser.frame', - 'pgadmin.browser.node', 'pgadmin.browser.collection', + 'pgadmin.browser.node', 'pgadmin.browser.collection', 'pgadmin.browser.activity', 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', 'pgadmin.browser.keyboard', 'sources/tree/pgadmin_tree_save_state', ], function( @@ -547,6 +547,11 @@ define('pgadmin.browser', [ obj.Events.on('pgadmin-browser:tree:loadfail', obj.onLoadFailNode, obj); obj.bind_beforeunload(); + + /* User UI activity */ + obj.log_activity(); /* The starting point */ + obj.register_to_activity_listener(document); + obj.start_inactivity_timeout_daemon(); }, init_master_password: function() { diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html index b2e0a8151..682c23d65 100644 --- a/web/pgadmin/browser/templates/browser/index.html +++ b/web/pgadmin/browser/templates/browser/index.html @@ -154,7 +154,7 @@ window.onload = function(e){
  • {{ _('Users') }}
  • {% endif %} -
  • {{ _('Logout') }}
  • +
  • {{ _('Logout') }}
  • diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index 79e812033..33dd6809e 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -45,6 +45,10 @@ define('pgadmin.browser.utils', pgAdmin['csrf_token_header'] = '{{ current_app.config.get('WTF_CSRF_HEADERS')[0] }}'; pgAdmin['csrf_token'] = '{{ csrf_token() }}'; + /* Get the inactivity related config */ + pgAdmin['user_inactivity_timeout'] = {{ current_app.config.get('USER_INACTIVITY_TIMEOUT') }}; + pgAdmin['override_user_inactivity_timeout'] = '{{ current_app.config.get('OVERRIDE_USER_INACTIVITY_TIMEOUT') }}' == 'True'; + // Define list of nodes on which Query tool option doesn't appears var unsupported_nodes = pgAdmin.unsupported_nodes = [ 'server_group', 'server', 'coll-tablespace', 'tablespace', @@ -65,6 +69,7 @@ define('pgadmin.browser.utils', app_name: '{{ app_name }}', pg_libpq_version: {{pg_libpq_version|e}}, support_ssh_tunnel: '{{ support_ssh_tunnel }}' == 'True', + logout_url: '{{logout_url}}', counter: {total: 0, loaded: 0}, registerScripts: function (ctx) { diff --git a/web/pgadmin/tools/debugger/static/js/direct.js b/web/pgadmin/tools/debugger/static/js/direct.js index 8e00091a5..9f1693501 100644 --- a/web/pgadmin/tools/debugger/static/js/direct.js +++ b/web/pgadmin/tools/debugger/static/js/direct.js @@ -1891,6 +1891,14 @@ define([ pgWindow.default.pgAdmin.Browser.onPreferencesChange('debugger', function() { self.reflectPreferences(); }); + + /* Register to log the activity */ + pgBrowser.register_to_activity_listener(document, ()=>{ + Alertify.alert(gettext('Timeout'), gettext('Your session has timedout due to inactivity !!')); + }); + + controller.poll_result = pgBrowser.override_activity_event_decorator(controller.poll_result).bind(controller); + controller.poll_end_execution_result = pgBrowser.override_activity_event_decorator(controller.poll_end_execution_result).bind(controller); }, reflectPreferences: function() { let self = this, diff --git a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js index 488f6282b..458533cdf 100644 --- a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js +++ b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js @@ -659,6 +659,11 @@ define('tools.querytool', [ } }, 1000); } + + /* Register to log the activity */ + pgBrowser.register_to_activity_listener(document, ()=>{ + alertify.alert(gettext('Timeout'), gettext('Your session has timedout due to inactivity !!')); + }); }, /* Regarding SlickGrid usage in render_grid function. @@ -2508,6 +2513,7 @@ define('tools.querytool', [ } const executeQuery = new ExecuteQuery.ExecuteQuery(this, pgAdmin.Browser.UserManagement); + executeQuery.poll = pgBrowser.override_activity_event_decorator(executeQuery.poll).bind(executeQuery); executeQuery.execute(sql, explain_prefix, shouldReconnect); }, diff --git a/web/regression/javascript/browser/activity_spec.js b/web/regression/javascript/browser/activity_spec.js new file mode 100644 index 000000000..816c373c4 --- /dev/null +++ b/web/regression/javascript/browser/activity_spec.js @@ -0,0 +1,174 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2020, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import {pgBrowser} from 'pgadmin.browser.activity'; +import { getEpoch } from 'sources/utils'; +import pgAdmin from 'sources/pgadmin'; + +describe('For Activity', function(){ + beforeEach(function(){ + pgAdmin.user_inactivity_timeout = 60; + pgAdmin.override_user_inactivity_timeout = true; + + /* pgBrowser here is same as main window Browser */ + window.pgAdmin = { + Browser: pgBrowser, + }; + }); + + describe('is_pgadmin_timedout', function(){ + it('when not timedout', function(){ + expect(pgBrowser.is_pgadmin_timedout()).toEqual(false); + }); + + it('when timedout', function(){ + window.pgAdmin = undefined; + expect(pgBrowser.is_pgadmin_timedout()).toEqual(true); + }); + }); + + describe('is_inactivity_timeout', function(){ + it('when there is activity', function(){ + window.pgAdmin.Browser.inactivity_timeout_at = getEpoch() + 30; + expect(pgBrowser.is_inactivity_timeout()).toEqual(false); + }); + + it('when there is no activity', function(){ + window.pgAdmin.Browser.inactivity_timeout_at = getEpoch() - 30; + expect(pgBrowser.is_inactivity_timeout()).toEqual(true); + }); + }); + + describe('log_activity', function(){ + beforeEach(function(){ + spyOn(pgBrowser, 'get_epoch_now').and.callThrough(); + spyOn(pgBrowser, 'log_activity').and.callThrough(); + pgBrowser.logging_activity = false; + }); + + it('initial log activity', function(){ + pgBrowser.log_activity(); + expect(window.pgAdmin.Browser.inactivity_timeout_at).not.toBe(null); + expect(pgBrowser.get_epoch_now).toHaveBeenCalled(); + }); + + it('multiple log activity within a second', function(){ + /* First call */ + pgBrowser.log_activity(); + expect(pgBrowser.get_epoch_now).toHaveBeenCalled(); + expect(pgBrowser.logging_activity).toEqual(true); + + /* Second call */ + pgBrowser.get_epoch_now.calls.reset(); + pgBrowser.log_activity(); + expect(pgBrowser.get_epoch_now).not.toHaveBeenCalled(); + }); + + it('set loggin to false after timeout', function(done){ + pgBrowser.log_activity(); + expect(pgBrowser.logging_activity).toEqual(true); + setTimeout(()=>{ + expect(pgBrowser.logging_activity).toEqual(false); + done(); + }, 1001); + }); + }); + + describe('register_to_activity_listener', function(){ + let target = document; + let timeout_callback = jasmine.createSpy(); + let event = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + }); + + beforeEach(function(){ + spyOn(pgBrowser, 'log_activity'); + spyOn(target, 'addEventListener').and.callThrough(); + spyOn(target, 'removeEventListener').and.callThrough(); + pgBrowser.register_to_activity_listener(target, timeout_callback); + }); + + it('function called', function(){ + expect(target.addEventListener).toHaveBeenCalled(); + }); + + it('event triggered', function(done){ + target.dispatchEvent(event); + + setTimeout(()=>{ + expect(pgBrowser.log_activity).toHaveBeenCalled(); + done(); + }, 250); + }); + + it('is timed out', function(done){ + spyOn(pgBrowser, 'is_pgadmin_timedout').and.returnValue(true); + target.dispatchEvent(event); + + setTimeout(()=>{ + expect(timeout_callback).toHaveBeenCalled(); + expect(target.removeEventListener).toHaveBeenCalled(); + done(); + }, 250); + }); + }); + + describe('override_activity_event_decorator', function(){ + let input_func = jasmine.createSpy('input_func'); + let decorate_func = pgBrowser.override_activity_event_decorator(input_func); + beforeEach(function(){ + spyOn(pgBrowser, 'log_activity').and.callThrough(); + }); + + it('call the input_func', function(){ + decorate_func(); + expect(input_func).toHaveBeenCalled(); + }); + + it('log activity when override_user_inactivity_timeout true', function(){ + decorate_func(); + expect(pgBrowser.log_activity).toHaveBeenCalled(); + }); + + it('do not log activity when override_user_inactivity_timeout true', function(){ + pgAdmin.override_user_inactivity_timeout = false; + decorate_func(); + expect(pgBrowser.log_activity).not.toHaveBeenCalled(); + }); + }); + + describe('start_inactivity_timeout_daemon', function(){ + beforeEach(function(){ + spyOn(pgBrowser, 'logout_inactivity_user'); + }); + + it('start the daemon', function(done){ + spyOn(pgBrowser, 'is_inactivity_timeout').and.returnValue(false); + pgBrowser.inactivity_timeout_daemon_running = false; + pgBrowser.start_inactivity_timeout_daemon(); + setTimeout(()=>{ + expect(pgBrowser.inactivity_timeout_daemon_running).toEqual(true); + done(); + }, 1001); + }); + + it('stop the daemon', function(done){ + spyOn(pgBrowser, 'is_inactivity_timeout').and.returnValue(true); + pgBrowser.inactivity_timeout_daemon_running = false; + pgBrowser.start_inactivity_timeout_daemon(); + setTimeout(()=>{ + expect(pgBrowser.inactivity_timeout_daemon_running).toEqual(false); + expect(pgBrowser.logout_inactivity_user).toHaveBeenCalled(); + done(); + }, 1001); + }); + }); +}); \ No newline at end of file diff --git a/web/webpack.shim.js b/web/webpack.shim.js index 77e86afdf..cf457ff60 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -189,6 +189,7 @@ var webpackShimConfig = { 'pgadmin.browser.layout': path.join(__dirname, './pgadmin/browser/static/js/layout'), 'pgadmin.browser.preferences': path.join(__dirname, './pgadmin/browser/static/js/preferences'), 'pgadmin.browser.menu': path.join(__dirname, './pgadmin/browser/static/js/menu'), + 'pgadmin.browser.activity': path.join(__dirname, './pgadmin/browser/static/js/activity'), 'pgadmin.browser.messages': '/browser/js/messages', 'pgadmin.browser.node': path.join(__dirname, './pgadmin/browser/static/js/node'), 'pgadmin.browser.node.ui': path.join(__dirname, './pgadmin/browser/static/js/node.ui'), diff --git a/web/webpack.test.config.js b/web/webpack.test.config.js index c8a5dc311..70db29125 100644 --- a/web/webpack.test.config.js +++ b/web/webpack.test.config.js @@ -16,6 +16,7 @@ const nodeModulesDir = path.resolve(__dirname, 'node_modules'); const regressionDir = path.resolve(__dirname, 'regression'); module.exports = { + mode: 'development', devtool: 'inline-source-map', plugins: [ new webpack.ProvidePlugin({ @@ -102,6 +103,7 @@ module.exports = { 'pgadmin.schema.dir': path.resolve(__dirname, 'pgadmin/browser/server_groups/servers/databases/schemas/static/js'), 'pgadmin.browser.layout': path.join(__dirname, './pgadmin/browser/static/js/layout'), 'pgadmin.browser.preferences': path.join(__dirname, './pgadmin/browser/static/js/preferences'), + 'pgadmin.browser.activity': path.join(__dirname, './pgadmin/browser/static/js/activity'), 'bundled_codemirror': path.join(__dirname, './pgadmin/static/bundle/codemirror'), }, },