diff --git a/platypush/backend/http/app/routes/login.py b/platypush/backend/http/app/routes/login.py index e1edeb5b..5aba3b6c 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 00000000..50bc0355 --- /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 354c961d..5eb538f0 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 00000000..c6fa0c3a --- /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 adaf8ef8..e488a912 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 00000000..53b3f9cf --- /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 00000000..451f77e0 --- /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 00000000..5452b9dd --- /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 790302af..28f8548f 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 9079ea00..76b231d2 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 00000000..4d4d03cf --- /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 cfd34a6b..009d1de7 100644 --- a/platypush/backend/http/templates/nav.html +++ b/platypush/backend/http/templates/nav.html @@ -27,15 +27,15 @@ {% endfor %} - -
-
- -
- -
-
+ +
+ + + + +
+
{% endwith %} diff --git a/platypush/backend/http/templates/settings/index.html b/platypush/backend/http/templates/settings/index.html new file mode 100644 index 00000000..486748f0 --- /dev/null +++ b/platypush/backend/http/templates/settings/index.html @@ -0,0 +1,47 @@ + + + Platypush Settings + + + + + + + + + + + + + + + + + {% include 'elements.html' %} + + + +
+ {% include 'settings/nav.html' %} + +
+ {% include 'settings/sections/users.html' %} + {% include 'settings/sections/token.html' %} +
+ + {% include 'notifications.html' %} +
+ + + + diff --git a/platypush/backend/http/templates/settings/nav.html b/platypush/backend/http/templates/settings/nav.html new file mode 100644 index 00000000..aaa4f767 --- /dev/null +++ b/platypush/backend/http/templates/settings/nav.html @@ -0,0 +1,47 @@ + + diff --git a/platypush/backend/http/templates/settings/sections/token.html b/platypush/backend/http/templates/settings/sections/token.html new file mode 100644 index 00000000..03b94a6e --- /dev/null +++ b/platypush/backend/http/templates/settings/sections/token.html @@ -0,0 +1,17 @@ +
+
+

Token

+
+ +
+ No token configured for requests. It is strongly advised to generate a random token (e.g. + dd if=/dev/urandom bs=18 count=1 | base64) and copy it in the token: + section of your configuration file. +
+ +
+ +
+
+ diff --git a/platypush/backend/http/templates/settings/sections/users.html b/platypush/backend/http/templates/settings/sections/users.html new file mode 100644 index 00000000..df55ce67 --- /dev/null +++ b/platypush/backend/http/templates/settings/sections/users.html @@ -0,0 +1,43 @@ +
+
+

Users

+
+ +
+
+ + +
+ + + + +
+
+ + +
+ + + + + +
+
+ +
+ + + + +
+
+ diff --git a/platypush/plugins/user.py b/platypush/plugins/user.py index 7b6d9b8b..953d147d 100644 --- a/platypush/plugins/user.py +++ b/platypush/plugins/user.py @@ -12,7 +12,8 @@ class UserPlugin(Plugin): self.user_manager = UserManager() @action - def create_user(self, username, password, executing_user=None, executing_user_password=None, **kwargs): + def create_user(self, username, password, executing_user=None, executing_user_password=None, session_token=None, + **kwargs): """ Create a user. This action needs to be executed by an already existing user, who needs to authenticate with their own credentials, unless this is the first user created on the system. @@ -29,11 +30,13 @@ class UserPlugin(Plugin): """ - if self.user_manager.get_user_count() > 0 and not executing_user: + if self.user_manager.get_user_count() > 0 and not executing_user and not session_token: return None, "You need to authenticate in order to create another user" if not self.user_manager.authenticate_user(executing_user, executing_user_password): - return None, "Invalid credentials" + user, session = self.user_manager.authenticate_user_session(session_token) + if not user: + return None, "Invalid credentials and/or session_token" try: user = self.user_manager.create_user(username, password, **kwargs) @@ -65,13 +68,15 @@ class UserPlugin(Plugin): return self.user_manager.update_password(username, old_password, new_password) @action - def delete_user(self, username, executing_user, executing_user_password): + def delete_user(self, username, executing_user=None, executing_user_password=None, session_token=None): """ Delete a user """ if not self.user_manager.authenticate_user(executing_user, executing_user_password): - return None, "Invalid credentials" + user, session = self.user_manager.authenticate_user_session(session_token) + if not user: + return None, "Invalid credentials and/or session_token" try: return self.user_manager.delete_user(username) @@ -123,7 +128,7 @@ class UserPlugin(Plugin): """ - user = self.user_manager.authenticate_user_session(session_token=session_token) + user, session = self.user_manager.authenticate_user_session(session_token=session_token) if not user: return None, 'Invalid session token' diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index 3b2a651c..69fc59bb 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -11,7 +11,6 @@ from sqlalchemy.ext.declarative import declarative_base from platypush.context import get_plugin Base = declarative_base() -Session = scoped_session(sessionmaker()) class UserManager: @@ -41,6 +40,10 @@ class UserManager: session = self._get_db_session() return session.query(User).count() + def get_users(self): + session = self._get_db_session() + return session.query(User) + def create_user(self, username, password, **kwargs): session = self._get_db_session() if not username: @@ -83,19 +86,23 @@ class UserManager: if not user_session or ( user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()): - return None + return None, None user = session.query(User).filter_by(user_id=user_session.user_id).first() # Hide password user.password = None - return user + return user, session def delete_user(self, username): session = self._get_db_session() user = self._get_user(session, username) if not user: - raise NameError('No such user: '.format(username)) + raise NameError('No such user: {}'.format(username)) + + user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all() + for user_session in user_sessions: + session.delete(user_session) session.delete(user) session.commit() @@ -125,11 +132,11 @@ class UserManager: user = self._get_user(session, username) user_session = UserSession(user_id=user.user_id, session_token=self._generate_token(), - created_at=datetime.datetime.utcnow(), expires_at=expires_at) + csrf_token=self._generate_token(), created_at=datetime.datetime.utcnow(), + expires_at=expires_at) session.add(user_session) session.commit() - return user_session @staticmethod @@ -151,9 +158,9 @@ class UserManager: def _get_db_session(self): Base.metadata.create_all(self._engine) - Session = scoped_session(sessionmaker()) - Session.configure(bind=self._engine) - return Session() + session = scoped_session(sessionmaker()) + session.configure(bind=self._engine) + return session() def _authenticate_user(self, session, username, password): user = self._get_user(session, username) @@ -183,6 +190,7 @@ class UserSession(Base): session_id = Column(Integer, primary_key=True) session_token = Column(String, unique=True, nullable=False) + csrf_token = Column(String, unique=True) user_id = Column(Integer, ForeignKey('user.user_id'), nullable=False) created_at = Column(DateTime) expires_at = Column(DateTime)