Merge pull request #610 from ngoduykhanh/warn_session_timeout

Warn session timeout
This commit is contained in:
Khanh Ngo 2019-12-19 09:07:44 +07:00 committed by GitHub
commit 88991cdde9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 289 additions and 57 deletions

View File

@ -3,10 +3,11 @@
"admin-lte": "2.4.9", "admin-lte": "2.4.9",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",
"bootstrap-validator": "^0.11.9", "bootstrap-validator": "^0.11.9",
"datatables.net-plugins": "^1.10.19",
"icheck": "^1.0.2", "icheck": "^1.0.2",
"jquery-slimscroll": "^1.3.8", "jquery-slimscroll": "^1.3.8",
"jquery-ui-dist": "^1.12.1", "jquery-ui-dist": "^1.12.1",
"multiselect": "^0.9.12", "jtimeout": "^3.1.0",
"datatables.net-plugins": "^1.10.19" "multiselect": "^0.9.12"
} }
} }

View File

@ -1,5 +1,6 @@
from flask_assets import Bundle, Environment, Filter from flask_assets import Bundle, Environment, Filter
class ConcatFilter(Filter): class ConcatFilter(Filter):
""" """
Filter that merges files, placing a semicolon between them. Filter that merges files, placing a semicolon between them.
@ -10,28 +11,23 @@ class ConcatFilter(Filter):
def concat(self, out, hunks, **kw): def concat(self, out, hunks, **kw):
out.write(';'.join([h.data() for h, info in hunks])) out.write(';'.join([h.data() for h, info in hunks]))
css_login = Bundle(
'node_modules/bootstrap/dist/css/bootstrap.css',
'node_modules/font-awesome/css/font-awesome.css',
'node_modules/ionicons/dist/css/ionicons.css',
'node_modules/icheck/skins/square/blue.css',
'node_modules/admin-lte/dist/css/AdminLTE.css',
filters=('cssmin','cssrewrite'),
output='generated/login.css'
)
js_login = Bundle( css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
'node_modules/jquery/dist/jquery.js', 'node_modules/font-awesome/css/font-awesome.css',
'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/ionicons/dist/css/ionicons.css',
'node_modules/icheck/icheck.js', 'node_modules/icheck/skins/square/blue.css',
filters=(ConcatFilter, 'jsmin'), 'node_modules/admin-lte/dist/css/AdminLTE.css',
output='generated/login.js' filters=('cssmin', 'cssrewrite'),
) output='generated/login.css')
js_validation = Bundle( js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap-validator/dist/validator.js', 'node_modules/bootstrap/dist/js/bootstrap.js',
output='generated/validation.js' 'node_modules/icheck/icheck.js',
) filters=(ConcatFilter, 'jsmin'),
output='generated/login.js')
js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.js',
output='generated/validation.js')
css_main = Bundle( css_main = Bundle(
'node_modules/bootstrap/dist/css/bootstrap.css', 'node_modules/bootstrap/dist/css/bootstrap.css',
@ -43,28 +39,26 @@ css_main = Bundle(
'node_modules/admin-lte/dist/css/AdminLTE.css', 'node_modules/admin-lte/dist/css/AdminLTE.css',
'node_modules/admin-lte/dist/css/skins/_all-skins.css', 'node_modules/admin-lte/dist/css/skins/_all-skins.css',
'custom/css/custom.css', 'custom/css/custom.css',
filters=('cssmin','cssrewrite'), filters=('cssmin', 'cssrewrite'),
output='generated/main.css' output='generated/main.css')
)
js_main = Bundle( js_main = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/jquery/dist/jquery.js', 'node_modules/jquery-ui-dist/jquery-ui.js',
'node_modules/jquery-ui-dist/jquery-ui.js', 'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/datatables.net/js/jquery.dataTables.js',
'node_modules/datatables.net/js/jquery.dataTables.js', 'node_modules/datatables.net-bs/js/dataTables.bootstrap.js',
'node_modules/datatables.net-bs/js/dataTables.bootstrap.js', 'node_modules/jquery-sparkline/jquery.sparkline.js',
'node_modules/jquery-sparkline/jquery.sparkline.js', 'node_modules/jquery-slimscroll/jquery.slimscroll.js',
'node_modules/jquery-slimscroll/jquery.slimscroll.js', 'node_modules/icheck/icheck.js',
'node_modules/icheck/icheck.js', 'node_modules/fastclick/lib/fastclick.js',
'node_modules/fastclick/lib/fastclick.js', 'node_modules/moment/moment.js',
'node_modules/moment/moment.js', 'node_modules/admin-lte/dist/js/adminlte.js',
'node_modules/admin-lte/dist/js/adminlte.js', 'node_modules/multiselect/js/jquery.multi-select.js',
'node_modules/multiselect/js/jquery.multi-select.js', 'node_modules/datatables.net-plugins/sorting/natural.js',
'node_modules/datatables.net-plugins/sorting/natural.js', 'node_modules/jtimeout/src/jTimeout.js',
'custom/js/custom.js', 'custom/js/custom.js',
filters=(ConcatFilter, 'jsmin'), filters=(ConcatFilter, 'jsmin'),
output='generated/main.js' output='generated/main.js')
)
assets = Environment() assets = Environment()
assets.register('js_login', js_login) assets.register('js_login', js_login)

View File

@ -26,6 +26,7 @@ class Setting(db.Model):
'bg_domain_updates': False, 'bg_domain_updates': False,
'site_name': 'PowerDNS-Admin', 'site_name': 'PowerDNS-Admin',
'session_timeout': 10, 'session_timeout': 10,
'warn_session_timeout': True,
'pdns_api_url': '', 'pdns_api_url': '',
'pdns_api_key': '', 'pdns_api_key': '',
'pdns_api_timeout': 30, 'pdns_api_timeout': 30,

View File

@ -1,7 +1,8 @@
import json import json
import datetime
import traceback import traceback
from ast import literal_eval from ast import literal_eval
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
from flask_login import login_required, current_user from flask_login import login_required, current_user
from ..decorators import operator_role_required, admin_role_required from ..decorators import operator_role_required, admin_role_required
@ -23,6 +24,16 @@ admin_bp = Blueprint('admin',
url_prefix='/admin') url_prefix='/admin')
@admin_bp.before_request
def before_request():
# Manage session timeout
session.permanent = True
current_app.permanent_session_lifetime = datetime.timedelta(
minutes=int(Setting().get('session_timeout')))
session.modified = True
@admin_bp.route('/pdns', methods=['GET']) @admin_bp.route('/pdns', methods=['GET'])
@login_required @login_required
@operator_role_required @operator_role_required
@ -489,7 +500,8 @@ def setting_basic():
'default_domain_table_size', 'auto_ptr', 'record_quick_edit', 'default_domain_table_size', 'auto_ptr', 'record_quick_edit',
'pretty_ipv6_ptr', 'dnssec_admins_only', 'pretty_ipv6_ptr', 'dnssec_admins_only',
'allow_user_create_domain', 'bg_domain_updates', 'site_name', 'allow_user_create_domain', 'bg_domain_updates', 'site_name',
'session_timeout', 'ttl_options', 'pdns_api_timeout' 'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout'
] ]
return render_template('admin_setting_basic.html', settings=settings) return render_template('admin_setting_basic.html', settings=settings)

View File

@ -1,6 +1,6 @@
import json import json
from urllib.parse import urljoin from urllib.parse import urljoin
from flask import Blueprint, g, request, abort, current_app from flask import Blueprint, g, request, abort, current_app, make_response, jsonify
from flask_login import current_user from flask_login import current_user
from ..models.base import db from ..models.base import db
@ -88,7 +88,16 @@ def handle_request_is_not_json(err):
@api_bp.before_request @api_bp.before_request
@is_json @is_json
def before_request(): def before_request():
pass # Check site is in maintenance mode
maintenance = Setting().get('maintenance')
if maintenance and current_user.is_authenticated and current_user.role.name not in [
'Administrator', 'Operator'
]:
return make_response(
jsonify({
"status": False,
"msg": "Site is in maintenance mode"
}))
@api_bp.route('/pdnsadmin/zones', methods=['POST']) @api_bp.route('/pdnsadmin/zones', methods=['POST'])
@ -281,7 +290,7 @@ def api_get_apikeys(domain_name):
if current_user.role.name not in ['Administrator', 'Operator']: if current_user.role.name not in ['Administrator', 'Operator']:
if domain_name: if domain_name:
msg = "Check if domain {0} exists and \ msg = "Check if domain {0} exists and \
is allowed for user." .format(domain_name) is allowed for user." .format(domain_name)
current_app.logger.debug(msg) current_app.logger.debug(msg)
apikeys = current_user.get_apikeys(domain_name) apikeys = current_user.get_apikeys(domain_name)

View File

@ -1,9 +1,10 @@
from flask import Blueprint, render_template, url_for, current_app, request, jsonify, redirect import datetime
from flask_login import login_required, current_user from flask import Blueprint, render_template, url_for, current_app, request, jsonify, redirect, g, session
from flask_login import login_required, current_user, login_manager
from sqlalchemy import not_ from sqlalchemy import not_
from ..lib.utils import customBoxes from ..lib.utils import customBoxes
from ..models.user import User from ..models.user import User, Anonymous
from ..models.account import Account from ..models.account import Account
from ..models.account_user import AccountUser from ..models.account_user import AccountUser
from ..models.domain import Domain from ..models.domain import Domain
@ -19,6 +20,26 @@ dashboard_bp = Blueprint('dashboard',
url_prefix='/dashboard') url_prefix='/dashboard')
@dashboard_bp.before_request
def before_request():
# Check if user is anonymous
g.user = current_user
login_manager.anonymous_user = Anonymous
# Check site is in maintenance mode
maintenance = Setting().get('maintenance')
if maintenance and current_user.is_authenticated and current_user.role.name not in [
'Administrator', 'Operator'
]:
return render_template('maintenance.html')
# Manage session timeout
session.permanent = True
current_app.permanent_session_lifetime = datetime.timedelta(
minutes=int(Setting().get('session_timeout')))
session.modified = True
@dashboard_bp.route('/domains-custom/<path:boxId>', methods=['GET']) @dashboard_bp.route('/domains-custom/<path:boxId>', methods=['GET'])
@login_required @login_required
def domains_custom(boxId): def domains_custom(boxId):

View File

@ -1,15 +1,16 @@
import re import re
import json import json
import datetime
import traceback import traceback
import dns.name import dns.name
import dns.reversename import dns.reversename
from distutils.version import StrictVersion from distutils.version import StrictVersion
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify, g, session
from flask_login import login_required, current_user from flask_login import login_required, current_user, login_manager
from ..lib.utils import pretty_json from ..lib.utils import pretty_json
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec
from ..models.user import User from ..models.user import User, Anonymous
from ..models.account import Account from ..models.account import Account
from ..models.setting import Setting from ..models.setting import Setting
from ..models.history import History from ..models.history import History
@ -26,6 +27,26 @@ domain_bp = Blueprint('domain',
url_prefix='/domain') url_prefix='/domain')
@domain_bp.before_request
def before_request():
# Check if user is anonymous
g.user = current_user
login_manager.anonymous_user = Anonymous
# Check site is in maintenance mode
maintenance = Setting().get('maintenance')
if maintenance and current_user.is_authenticated and current_user.role.name not in [
'Administrator', 'Operator'
]:
return render_template('maintenance.html')
# Manage session timeout
session.permanent = True
current_app.permanent_session_lifetime = datetime.timedelta(
minutes=int(Setting().get('session_timeout')))
session.modified = True
@domain_bp.route('/<path:domain_name>', methods=['GET']) @domain_bp.route('/<path:domain_name>', methods=['GET'])
@login_required @login_required
@can_access_domain @can_access_domain

View File

@ -82,6 +82,12 @@ def index():
return redirect(url_for('dashboard.dashboard')) return redirect(url_for('dashboard.dashboard'))
@index_bp.route('/ping', methods=['GET'])
@login_required
def ping():
return make_response('ok')
@index_bp.route('/google/login') @index_bp.route('/google/login')
def google_login(): def google_login():
if not Setting().get('google_oauth_enabled') or google is None: if not Setting().get('google_oauth_enabled') or google is None:

View File

@ -1,10 +1,12 @@
import datetime
import qrcode as qrc import qrcode as qrc
import qrcode.image.svg as qrc_svg import qrcode.image.svg as qrc_svg
from io import BytesIO from io import BytesIO
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, session from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
from flask_login import current_user, login_required from flask_login import current_user, login_required, login_manager
from ..models.user import User from ..models.user import User, Anonymous
from ..models.setting import Setting
user_bp = Blueprint('user', user_bp = Blueprint('user',
__name__, __name__,
@ -12,6 +14,26 @@ user_bp = Blueprint('user',
url_prefix='/user') url_prefix='/user')
@user_bp.before_request
def before_request():
# Check if user is anonymous
g.user = current_user
login_manager.anonymous_user = Anonymous
# Check site is in maintenance mode
maintenance = Setting().get('maintenance')
if maintenance and current_user.is_authenticated and current_user.role.name not in [
'Administrator', 'Operator'
]:
return render_template('maintenance.html')
# Manage session timeout
session.permanent = True
current_app.permanent_session_lifetime = datetime.timedelta(
minutes=int(Setting().get('session_timeout')))
session.modified = True
@user_bp.route('/profile', methods=['GET', 'POST']) @user_bp.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
def profile(): def profile():

View File

@ -268,3 +268,20 @@ json_library = {
.replace(jsonLine, json_library.replacer); .replace(jsonLine, json_library.replacer);
} }
}; };
// set count down in second on an element
function timer(elToUpdate, maxTime) {
elToUpdate.text(maxTime + "s");
var interval = setInterval(function () {
if (maxTime > 0) {
maxTime--;
elToUpdate.text(maxTime + "s");
}
else {
clearInterval(interval);
}
}, 1000);
return interval;
}

View File

@ -174,6 +174,99 @@
{% block scripts %} {% block scripts %}
{% assets "js_main" -%} {% assets "js_main" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script> <script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% if SETTING.get('warn_session_timeout') %}
<script>
// close the session warning popup when receive
// a boradcast message
var bc = new BroadcastChannel('powerdnsadmin');
bc.addEventListener('message', function (e) {
if (e.data == 'close_session_timeout_modal'){
$("#modal_session_warning").modal('hide');
}
});
// Stay Signed In button click event
$(document.body).on("click", ".button_stay_signed_in", function (e) {
$.get({
url: $.jTimeout().options.extendUrl,
cache: false,
success: function(){
$.jTimeout().resetExpiration();
}
});
$.jTimeout().options.onClickExtend();
});
// Sign Out button click event
$(document.body).on("click", ".button_sign_out", function (e) {
window.location.replace("{{ url_for('index.logout') }}");
});
// Things happen when session warning popup shown
$(document).on('show.bs.modal','#modal_session_warning', function () {
var secondsLeft = jTimeout.getSecondsTillExpiration();
var t = timer($('#modal-time'), secondsLeft);
$(this).on('hidden.bs.modal', function () {
clearInterval(t);
$('#modal-time').text("");
$(this).off('hidden.bs.modal');
});
});
// jTimeout definition
$(function(){
$.jTimeout({
flashTitle: true,
flashTitleSpeed: 500,
flashingTitleText: '**WARNING**',
originalTitle: document.title,
timeoutAfter: {{ SETTING.get('session_timeout')|int * 60 }},
secondsPrior: 60,
heartbeat: 1,
extendOnMouseMove: true,
mouseDebounce: 30,
extendUrl: '{{ url_for("index.ping") }}',
logoutUrl: '{{ url_for("index.logout") }}',
loginUrl: '{{ url_for("index.login") }}',
onClickExtend: function(){
// broadcast a message to tell other tabes
// close the session warning popup
var bc = new BroadcastChannel('powerdnsadmin');
bc.postMessage('close_session_timeout_modal');
},
onMouseMove: function(){
// if the mouse is moving while popup is present, we
// don't extend the session.
if (!$('#modal_session_warning').hasClass('in')) {
$.get({
url: $.jTimeout().options.extendUrl,
cache: false,
success: function () {
$.jTimeout().resetExpiration();
}
});
}
},
onTimeout: function(jTimeout){
window.location.replace("{{ url_for('index.logout') }}");
},
onPriorCallback: function(jTimeout){
$("#modal_session_warning").modal('show');;
},
onSessionExtended:function(jTimeout){
$("#modal_session_warning").modal('hide');
}
});
});
</script>
{% endif %}
{%- endassets %} {%- endassets %}
{% endblock %} {% endblock %}
{% block extrascripts %} {% block extrascripts %}
@ -225,6 +318,30 @@
<!-- /.modal-dialog --> <!-- /.modal-dialog -->
</div> </div>
<!-- /.modal --> <!-- /.modal -->
<!-- /.session-warning-modal -->
<div class="modal fade modal-warning" data-backdrop="static" id="modal_session_warning">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Session timeout warning</h4>
</div>
<div class="modal-body">
<p>Your session is about to expire. You will be automatically signed out in</p>
<h3><span id="modal-time"></span></h3>
<p>To coninue your ssession, select <strong>Stay Signed In</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-danger pull-left button_stay_signed_in"
data-dismiss="modal">Stay Signed In</button>
<button type="button" class="btn btn-flat btn-default pull-right button_sign_out"
data-dismiss="modal">Sign Out</button>
</div>
</div>
<!-- /.session-warning-modal-content -->
</div>
<!-- /.session-warning-modal-dialog -->
</div>
<!-- /.session-warning-modal -->
{% endblock %} {% endblock %}
{% block modals %} {% block modals %}
{% endblock %} {% endblock %}

View File

@ -51,6 +51,9 @@ class TestUnitApiZoneAdminApiKey(object):
self.mock_hist_patcher = patch( self.mock_hist_patcher = patch(
'powerdnsadmin.routes.api.History', 'powerdnsadmin.routes.api.History',
spec=powerdnsadmin.models.history.History) spec=powerdnsadmin.models.history.History)
self.mock_setting_patcher = patch(
'powerdnsadmin.routes.api.Setting',
spec=powerdnsadmin.models.setting.Setting)
data = admin_apikey_data() data = admin_apikey_data()
api_key = ApiKey(desc=data['description'], api_key = ApiKey(desc=data['description'],
@ -71,6 +74,7 @@ class TestUnitApiZoneAdminApiKey(object):
) )
self.mock_apikey = self.mock_apikey_patcher.start() self.mock_apikey = self.mock_apikey_patcher.start()
self.mock_hist = self.mock_hist_patcher.start() self.mock_hist = self.mock_hist_patcher.start()
self.mock_setting = self.mock_setting_patcher.start()
self.mock_google_setting.return_value.get.side_effect = load_data self.mock_google_setting.return_value.get.side_effect = load_data
self.mock_github_setting.return_value.get.side_effect = load_data self.mock_github_setting.return_value.get.side_effect = load_data

View File

@ -701,6 +701,13 @@ jsonparse@^1.2.0:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
jtimeout@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/jtimeout/-/jtimeout-3.1.0.tgz#4cd65b28eff8b9f8c61d08889a9ac3abdf5d9893"
integrity sha512-xA2TlImMGj4c0yAiM9BUq+8aAFVYVYUX2tkcC8u8das9qoZSs13SxhVcfWqI4cHOsv3huj2D0VRNHeVCLO3mVQ==
dependencies:
jquery ">=1.7.1 <4.0.0"
jvectormap@^1.2.2: jvectormap@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/jvectormap/-/jvectormap-1.2.2.tgz#2e4408b24a60473ff106c1e7243e375ae5ca85da" resolved "https://registry.yarnpkg.com/jvectormap/-/jvectormap-1.2.2.tgz#2e4408b24a60473ff106c1e7243e375ae5ca85da"