diff --git a/platypush/backend/http/app/routes/login.py b/platypush/backend/http/app/routes/login.py index e1edeb5b6..5aba3b6c8 100644 --- a/platypush/backend/http/app/routes/login.py +++ b/platypush/backend/http/app/routes/login.py @@ -1,6 +1,7 @@ import datetime +import re -from flask import Blueprint, request, redirect, render_template, make_response, url_for +from flask import Blueprint, request, redirect, render_template, make_response from platypush.backend.http.app import template_folder from platypush.backend.http.utils import HttpUtils @@ -18,11 +19,18 @@ __routes__ = [ def login(): """ Login page """ user_manager = UserManager() - redirect_page = request.args.get('redirect', '/') session_token = request.cookies.get('session_token') + # redirect_page = request.args.get('redirect', request.headers.get('Referer', '/')) + redirect_page = request.args.get('redirect') + if not redirect_page: + redirect_page = request.headers.get('Referer', '/') + if re.search('(^https?://[^/]+)?/login[^?#]?', redirect_page): + # Prevent redirect loop + redirect_page = '/' + if session_token: - user = user_manager.authenticate_user_session(session_token) + user, session = user_manager.authenticate_user_session(session_token) if user: return redirect(redirect_page, 302) diff --git a/platypush/backend/http/app/routes/logout.py b/platypush/backend/http/app/routes/logout.py new file mode 100644 index 000000000..50bc0355d --- /dev/null +++ b/platypush/backend/http/app/routes/logout.py @@ -0,0 +1,34 @@ +from flask import Blueprint, request, redirect, make_response, abort + +from platypush.backend.http.app import template_folder +from platypush.user import UserManager + +logout = Blueprint('logout', __name__, template_folder=template_folder) + +# Declare routes list +__routes__ = [ + logout, +] + + +@logout.route('/logout', methods=['GET', 'POST']) +def logout(): + """ Logout page """ + user_manager = UserManager() + redirect_page = request.args.get('redirect', request.headers.get('Referer', '/login')) + session_token = request.cookies.get('session_token') + + if not session_token: + return abort(417, 'Not logged in') + + user, session = user_manager.authenticate_user_session(session_token) + if not user: + return abort(403, 'Invalid session token') + + redirect_target = redirect(redirect_page, 302) + response = make_response(redirect_target) + response.set_cookie('session_token', '', expires=0) + return response + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/app/routes/register.py b/platypush/backend/http/app/routes/register.py index 354c961df..5eb538f0c 100644 --- a/platypush/backend/http/app/routes/register.py +++ b/platypush/backend/http/app/routes/register.py @@ -22,7 +22,7 @@ def register(): session_token = request.cookies.get('session_token') if session_token: - user = user_manager.authenticate_user_session(session_token) + user, session = user_manager.authenticate_user_session(session_token) if user: return redirect(redirect_page, 302) diff --git a/platypush/backend/http/app/routes/settings.py b/platypush/backend/http/app/routes/settings.py new file mode 100644 index 000000000..c6fa0c3a5 --- /dev/null +++ b/platypush/backend/http/app/routes/settings.py @@ -0,0 +1,26 @@ +from flask import Blueprint, request, render_template + +from platypush.backend.http.app import template_folder +from platypush.backend.http.app.utils import authenticate +from platypush.backend.http.utils import HttpUtils +from platypush.config import Config +from platypush.user import UserManager + +settings = Blueprint('settings', __name__, template_folder=template_folder) + +# Declare routes list +__routes__ = [ + settings, +] + + +@settings.route('/settings', methods=['GET']) +@authenticate() +def settings(): + """ Settings page """ + user_manager = UserManager() + users = user_manager.get_users() + return render_template('settings/index.html', utils=HttpUtils, users=users, token=Config.get('token')) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/app/utils.py b/platypush/backend/http/app/utils.py index adaf8ef87..e488a9129 100644 --- a/platypush/backend/http/app/utils.py +++ b/platypush/backend/http/app/utils.py @@ -146,12 +146,33 @@ def _authenticate_session(): user_session_token = request.cookies.get('session_token') if user_session_token: - user = user_manager.authenticate_user_session(user_session_token) + user, session = user_manager.authenticate_user_session(user_session_token) return user is not None -def authenticate(redirect_page='', skip_auth_methods=None): +def _authenticate_csrf_token(): + user_manager = UserManager() + user_session_token = None + user = None + + if 'X-Session-Token' in request.headers: + user_session_token = request.headers['X-Session-Token'] + elif 'session_token' in request.args: + user_session_token = request.args.get('session_token') + elif 'session_token' in request.cookies: + user_session_token = request.cookies.get('session_token') + + if user_session_token: + user, session = user_manager.authenticate_user_session(user_session_token) + + if user is None: + return False + + return session.csrf_token is None or request.form.get('csrf_token') == session.csrf_token + + +def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): @@ -183,6 +204,13 @@ def authenticate(redirect_page='', skip_auth_methods=None): return redirect('/login?redirect=' + redirect_page, 307) + # CSRF token check + csrf_check_ok = True + if check_csrf_token: + csrf_check_ok = _authenticate_csrf_token() + if not csrf_check_ok: + return abort(403, 'Invalid or missing csrf_token') + if n_users == 0 and 'session' not in skip_methods: return redirect('/register?redirect=' + redirect_page, 307) diff --git a/platypush/backend/http/static/css/source/settings/index.scss b/platypush/backend/http/static/css/source/settings/index.scss new file mode 100644 index 000000000..53b3f9cfe --- /dev/null +++ b/platypush/backend/http/static/css/source/settings/index.scss @@ -0,0 +1,111 @@ +@import 'common/vars'; + +@import 'common/mixins'; +@import 'common/layout'; +@import 'common/elements'; +@import 'common/animations'; +@import 'common/modal'; +@import 'common/notifications'; + +@import 'settings/users'; +@import 'settings/token'; + +$nav-background: #f0f0f0; +$section-header-background: #eee; + +body { + width: 100%; + height: 100%; + margin: 0; + overflow-x: hidden; + font-family: $default-font-family; + font-size: $default-font-size; +} + +#app { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: row; + + nav { + background: $nav-background; + color: $default-link-fg; + border-right: $default-border-3; + + ul { + width: 100%; + height: 100%; + margin: 0; + overflow: auto; + list-style-type: none; + + li { + display: flex; + margin: 0; + padding: 1em; + cursor: pointer; + + &:not(:first-of-type) { + border-top: $default-border-3; + } + + &:hover { + background: $hover-bg; + } + + a:first-child { + width: 100%; + } + } + } + } + + main { + margin: 0; + flex: 1 1 auto; + overflow: hidden; + height: inherit; + + .section { + overflow: auto; + height: inherit; + + header { + display: flex; + align-items: center; + border-bottom: $default-border-2; + background: $section-header-background; + + h1 { + margin: .45em .2em; + } + + button { + border: 0; + &:hover { + color: $default-hover-fg; + } + } + } + } + } + + a { + color: $default-link-fg; + } +} + +.dropdown { + .item { + display: flex; + flex-direction: row; + cursor: pointer; + + &:hover { + background: $hover-bg; + } + + } +} + diff --git a/platypush/backend/http/static/css/source/settings/token.scss b/platypush/backend/http/static/css/source/settings/token.scss new file mode 100644 index 000000000..451f77e0d --- /dev/null +++ b/platypush/backend/http/static/css/source/settings/token.scss @@ -0,0 +1,21 @@ +#token { + .warning { + background: $notification-error-bg; + border: $notification-error-border; + margin: 1em; + padding: 1em; + border-radius: 1em; + } + + .token-container { + display: flex; + align-items: center; + justify-content: center; + margin: 2em; + + input { + width: 100%; + } + } +} + diff --git a/platypush/backend/http/static/css/source/settings/users.scss b/platypush/backend/http/static/css/source/settings/users.scss new file mode 100644 index 000000000..5452b9dd0 --- /dev/null +++ b/platypush/backend/http/static/css/source/settings/users.scss @@ -0,0 +1,27 @@ +#users { + ul { + list-style-type: none; + + li { + margin: 0; + padding: 1em .5em; + border-bottom: $default-border-3; + cursor: pointer; + + &:hover { + background: $hover-bg; + } + } + } + + form { + display: flex; + flex-direction: column; + width: 20em; + + input { + margin: 1em .5em; + } + } +} + diff --git a/platypush/backend/http/static/css/source/webpanel/nav.scss b/platypush/backend/http/static/css/source/webpanel/nav.scss index 790302af5..28f8548ff 100644 --- a/platypush/backend/http/static/css/source/webpanel/nav.scss +++ b/platypush/backend/http/static/css/source/webpanel/nav.scss @@ -4,21 +4,23 @@ nav { width: 100%; + position: relative; height: var(--nav-height); + background: $nav-bg; margin-bottom: $nav-margin; + border-bottom: $default-bottom; + box-shadow: 0 2.5px 4px 0 #bbb; flex: 0 1 auto; z-index: 2; ul { position: relative; + width: 75%; + display: inline-flex; margin: 0; padding: 0; list-style-type: none; - background: $nav-bg; - display: flex; align-items: center; - border-bottom: $default-bottom; - box-shadow: 0 2.5px 4px 0 #bbb; li { padding: 1rem 1.5rem; @@ -44,42 +46,6 @@ nav { } } - .date-time { - position: absolute; - display: inline-block; - right: 0; - margin-right: .7rem; - font-size: 14pt; - text-shadow: $nav-date-time-shadow; - - .time { - display: inline-block; - } - } - - .settings { - display: inline-block; - cursor: pointer; - padding: .75rem 1rem; - - &:hover { - background: $hover-bg; - border-radius: 3rem; - } - } - - .decorator { - width: 0; - height: 0; - border-top: 20px solid transparent; - border-bottom: 20px solid transparent; - border-left: 17px solid $selected-bg; - - &:hover { - display: none; - } - } - &:hover { .decorator { display: none; @@ -90,5 +56,29 @@ nav { } } } + + .date-time { + position: absolute; + width: 25%; + display: inline-block; + right: 0; + margin-right: .7rem; + font-size: 14pt; + text-shadow: $nav-date-time-shadow; + + .time { + display: inline-block; + } + } + + .settings { + display: inline-block; + padding: .75rem 1rem; + + &:hover { + background: $hover-bg; + border-radius: 3rem; + } + } } diff --git a/platypush/backend/http/static/js/application.js b/platypush/backend/http/static/js/application.js index 9079ea006..76b231d2d 100644 --- a/platypush/backend/http/static/js/application.js +++ b/platypush/backend/http/static/js/application.js @@ -1,19 +1,3 @@ -Vue.component('app-header', { - template: '#tmpl-app-header', - data: function() { - return { - now: new Date(), - }; - }, - - created: function() { - const self = this; - setInterval(() => { - self.now = new Date(); - }, 1000) - }, -}); - Vue.component('plugin', { template: '#tmpl-plugin', props: ['config','tag'], @@ -43,7 +27,7 @@ window.vm = new Vue({ const self = this; setInterval(() => { self.now = new Date(); - }, 1000) + }, 1000); initEvents(); }, diff --git a/platypush/backend/http/static/js/settings/index.js b/platypush/backend/http/static/js/settings/index.js new file mode 100644 index 000000000..4d4d03cfe --- /dev/null +++ b/platypush/backend/http/static/js/settings/index.js @@ -0,0 +1,184 @@ +window.vm = new Vue({ + el: '#app', + data: function() { + return { + config: window.config, + sessionToken: undefined, + selectedTab: 'users', + selectedUser: undefined, + + modalVisible: { + addUser: false, + changePassword: false, + }, + + formDisabled: { + addUser: false, + changePassword: false, + }, + }; + }, + + computed: { + userDropdownItems: function() { + const self = this; + return [ + { + text: 'Change password', + iconClass: 'fas fa-key', + click: function() { + self.modalVisible.changePassword = true; + }, + }, + { + text: 'Delete user', + iconClass: 'fa fa-trash', + click: async function() { + if (!confirm('Are you sure that you want to remove the user ' + self.selectedUser + '?')) + return; + + await request('user.delete_user', { + username: self.selectedUser, + session_token: self.sessionToken, + }); + + createNotification({ + text: 'User ' + self.selectedUser + ' removed', + image: { + iconClass: 'fas fa-check', + }, + }); + + window.location.reload(); + }, + }, + ]; + }, + }, + + methods: { + createUser: async function(event) { + event.preventDefault(); + + let form = [...this.$refs.createUserForm.querySelectorAll('input[name]')].reduce((map, input) => { + map[input.name] = input.value; + return map; + }, {}); + + if (form.password != form.confirm_password) { + createNotification({ + text: 'Please check that the passwords match', + image: { + iconClass: 'fas fa-times', + }, + error: true, + }); + + return; + } + + this.formDisabled.addUser = true; + await request('user.create_user', { + username: form.username, + password: form.password, + session_token: this.sessionToken, + }); + + this.formDisabled.addUser = false; + createNotification({ + text: 'User ' + form.username + ' created', + image: { + iconClass: 'fas fa-check', + }, + }); + + this.modalVisible.addUser = false; + window.location.reload(); + }, + + onUserClick: function(username) { + this.selectedUser = username; + openDropdown(this.$refs.userDropdown.$el); + }, + + onTokenFocus: function(event) { + event.target.select(); + document.execCommand('copy'); + event.target.setAttribute('disabled', true); + + createNotification({ + text: 'Token copied to clipboard', + image: { + iconClass: 'fas fa-copy', + }, + }); + }, + + onTokenBlur: function(event) { + event.target.select(); + document.execCommand('copy'); + event.target.removeAttribute('disabled'); + + createNotification({ + text: 'Token copied to clipboard', + image: { + iconClass: 'fas fa-copy', + }, + }); + }, + + changePassword: async function(event) { + event.preventDefault(); + + let form = [...this.$refs.changePasswordForm.querySelectorAll('input[name]')].reduce((map, input) => { + map[input.name] = input.value; + return map; + }, {}); + + if (form.new_password !== form.confirm_new_password) { + createNotification({ + text: 'Please check that the passwords match', + image: { + iconClass: 'fas fa-times', + }, + error: true, + }); + + return; + } + + this.formDisabled.changePassword = true; + let success = await request('user.update_password', { + username: form.username, + old_password: form.password, + new_password: form.new_password, + }); + + this.formDisabled.changePassword = false; + + if (success) { + this.modalVisible.changePassword = false; + createNotification({ + text: 'Password successfully updated', + image: { + iconClass: 'fas fa-check', + }, + }); + } else { + createNotification({ + text: 'Unable to update password: the current password is incorrect', + image: { + iconClass: 'fas fa-times', + }, + error: true, + }); + } + }, + }, + + created: function() { + let cookies = Object.fromEntries(document.cookie.split('; ').map(x => x.split('='))); + this.sessionToken = cookies.session_token; + }, +}); + diff --git a/platypush/backend/http/templates/nav.html b/platypush/backend/http/templates/nav.html index cfd34a6b1..009d1de7f 100644 --- a/platypush/backend/http/templates/nav.html +++ b/platypush/backend/http/templates/nav.html @@ -27,15 +27,15 @@ {% endfor %} - -