Implemented settings page and finalized multi-user support

This commit is contained in:
Fabio Manganiello 2019-07-19 00:50:45 +02:00
parent cd9bdbb1c8
commit f86e2eb5a7
18 changed files with 666 additions and 86 deletions

View File

@ -1,6 +1,7 @@
import datetime 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.app import template_folder
from platypush.backend.http.utils import HttpUtils from platypush.backend.http.utils import HttpUtils
@ -18,11 +19,18 @@ __routes__ = [
def login(): def login():
""" Login page """ """ Login page """
user_manager = UserManager() user_manager = UserManager()
redirect_page = request.args.get('redirect', '/')
session_token = request.cookies.get('session_token') 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: if session_token:
user = user_manager.authenticate_user_session(session_token) user, session = user_manager.authenticate_user_session(session_token)
if user: if user:
return redirect(redirect_page, 302) return redirect(redirect_page, 302)

View File

@ -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:

View File

@ -22,7 +22,7 @@ def register():
session_token = request.cookies.get('session_token') session_token = request.cookies.get('session_token')
if session_token: if session_token:
user = user_manager.authenticate_user_session(session_token) user, session = user_manager.authenticate_user_session(session_token)
if user: if user:
return redirect(redirect_page, 302) return redirect(redirect_page, 302)

View File

@ -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:

View File

@ -146,12 +146,33 @@ def _authenticate_session():
user_session_token = request.cookies.get('session_token') user_session_token = request.cookies.get('session_token')
if user_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 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): def decorator(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -183,6 +204,13 @@ def authenticate(redirect_page='', skip_auth_methods=None):
return redirect('/login?redirect=' + redirect_page, 307) 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: if n_users == 0 and 'session' not in skip_methods:
return redirect('/register?redirect=' + redirect_page, 307) return redirect('/register?redirect=' + redirect_page, 307)

View File

@ -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;
}
}
}

View File

@ -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%;
}
}
}

View File

@ -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;
}
}
}

View File

@ -4,21 +4,23 @@
nav { nav {
width: 100%; width: 100%;
position: relative;
height: var(--nav-height); height: var(--nav-height);
background: $nav-bg;
margin-bottom: $nav-margin; margin-bottom: $nav-margin;
border-bottom: $default-bottom;
box-shadow: 0 2.5px 4px 0 #bbb;
flex: 0 1 auto; flex: 0 1 auto;
z-index: 2; z-index: 2;
ul { ul {
position: relative; position: relative;
width: 75%;
display: inline-flex;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style-type: none; list-style-type: none;
background: $nav-bg;
display: flex;
align-items: center; align-items: center;
border-bottom: $default-bottom;
box-shadow: 0 2.5px 4px 0 #bbb;
li { li {
padding: 1rem 1.5rem; 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 { &:hover {
.decorator { .decorator {
display: none; 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;
}
}
} }

View File

@ -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', { Vue.component('plugin', {
template: '#tmpl-plugin', template: '#tmpl-plugin',
props: ['config','tag'], props: ['config','tag'],
@ -43,7 +27,7 @@ window.vm = new Vue({
const self = this; const self = this;
setInterval(() => { setInterval(() => {
self.now = new Date(); self.now = new Date();
}, 1000) }, 1000);
initEvents(); initEvents();
}, },

View File

@ -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;
},
});

View File

@ -27,15 +27,15 @@
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
<div class="date-time pull-right">
<div class="settings">
<i class="fas fa-cog"></i>
</div>
<div class="time" v-text="now.toTimeString().substring(0,8)"></div>
</div>
</ul> </ul>
<div class="date-time pull-right">
<a href="/settings" class="settings">
<i class="fas fa-cog"></i>
</a>
<div class="time" v-text="now.toTimeString().substring(0,8)"></div>
</div>
</nav> </nav>
{% endwith %} {% endwith %}

View File

@ -0,0 +1,47 @@
<!doctype html>
<head>
<title>Platypush Settings</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="stylesheet" href="{{ url_for('static', filename='css/skeleton.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome/css/all.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dist/settings.css') }}">
<script type="text/javascript">
if (!window.config) {
window.config = {};
}
{% if token %}
window.config.token = '{{ token }}';
{% else %}
window.config.token = undefined;
{% endif %}
</script>
<script type="text/javascript" src="{{ url_for('static', filename='js/lib/vue.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/lib/axios.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/api.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/notifications.js') }}"></script>
{% include 'elements.html' %}
</head>
<body>
<div id="app">
{% include 'settings/nav.html' %}
<main class="col-s-8 col-m-9 col-l-10">
{% include 'settings/sections/users.html' %}
{% include 'settings/sections/token.html' %}
</main>
{% include 'notifications.html' %}
</div>
<script type="text/javascript" src="{{ url_for('static', filename='js/settings/index.js') }}"></script>
</body>

View File

@ -0,0 +1,47 @@
<nav class="col-s-4 col-m-3 col-l-2">
<ul>
<li :class="{selected: selectedTab === 'back'}" @click="selectedTab = 'back'">
<a :href="document.referrer" v-if="document.referrer">
<div class="col-3 icon">
<i class="fas fa-caret-left"></i>
</div>
<div class="col-9 title">
Go Back
</div>
</a>
<a href="/" v-else>
<div class="col-3 icon">
<i class="fas fa-home"></i>
</div>
<div class="col-9 title">
Home
</div>
</a>
</li>
<li :class="{selected: selectedTab === 'users'}" @click="selectedTab = 'users'">
<div class="col-3 icon">
<i class="fas fa-user"></i>
</div>
<div class="col-9 title">Users</div>
</li>
<li :class="{selected: selectedTab === 'token'}" @click="selectedTab = 'token'">
<div class="col-3 icon">
<i class="fas fa-key"></i>
</div>
<div class="col-9 title">Token</div>
</li>
<li :class="{selected: selectedTab === 'logout'}" @click="selectedTab = 'logout'">
<a href="/logout">
<div class="col-3 icon">
<i class="fas fa-sign-out-alt"></i>
</div>
<div class="col-9 title">Logout</div>
</a>
</li>
</ul>
</nav>

View File

@ -0,0 +1,17 @@
<div class="section" :class="{hidden: selectedTab !== 'token'}" id="token">
<header>
<h1>Token</h1>
</header>
<div class="warning" v-if="!config.token">
No token configured for requests. It is strongly advised to generate a random token (e.g.
<tt>dd if=/dev/urandom bs=18 count=1 | base64</tt>) and copy it in the <tt>token:</tt>
section of your configuration file.
</div>
<div class="token-container" v-else>
<input type="text" :value="config.token"
@focus="onTokenFocus" @blur="onTokenBlur">
</div>
</div>

View File

@ -0,0 +1,43 @@
<div class="section" :class="{hidden: selectedTab !== 'users'}" id="users">
<header>
<h1 class="title col-8">Users</h1>
<div class="col-4 pull-right">
<button type="button">
<i class="fa fa-plus" title="Add User" @click="modalVisible.addUser = true"></i>
</button>
</div>
</header>
<modal id="add-user" title="Add User" v-model="modalVisible.addUser">
<form action="#" method="POST" ref="createUserForm" @submit="createUser">
<input type="text" name="username" placeholder="Username" :disabled="formDisabled.addUser">
<input type="password" name="password" placeholder="Password" :disabled="formDisabled.addUser">
<input type="password" name="confirm_password" placeholder="Confirm password" :disabled="formDisabled.addUser">
<input type="submit" value="Create User" :disabled="formDisabled.addUser">
</form>
</modal>
<modal id="change-password" title="Change password" v-model="modalVisible.changePassword" v-if="selectedUser">
<form action="#" method="POST" ref="changePasswordForm" @submit="changePassword">
<input type="text" name="username" placeholder="Username" :value="selectedUser" disabled="disabled">
<input type="password" name="password" placeholder="Current password" :disabled="formDisabled.changePassword">
<input type="password" name="new_password" placeholder="New password" :disabled="formDisabled.changePassword">
<input type="password" name="confirm_new_password" placeholder="Confirm new password" :disabled="formDisabled.changePassword">
<input type="submit" value="Change Password" :disabled="formDisabled.changePassword">
</form>
</modal>
<div class="body">
<ul>
{% for user in users %}
<li @click="onUserClick('{{ user.username }}')">{{ user.username }}</li>
{% endfor %}
</ul>
<dropdown id="user-dropdown"
ref="userDropdown"
:items="userDropdownItems">
</dropdown>
</div>
</div>

View File

@ -12,7 +12,8 @@ class UserPlugin(Plugin):
self.user_manager = UserManager() self.user_manager = UserManager()
@action @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 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. 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" return None, "You need to authenticate in order to create another user"
if not self.user_manager.authenticate_user(executing_user, executing_user_password): 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: try:
user = self.user_manager.create_user(username, password, **kwargs) 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) return self.user_manager.update_password(username, old_password, new_password)
@action @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 Delete a user
""" """
if not self.user_manager.authenticate_user(executing_user, executing_user_password): 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: try:
return self.user_manager.delete_user(username) 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: if not user:
return None, 'Invalid session token' return None, 'Invalid session token'

View File

@ -11,7 +11,6 @@ from sqlalchemy.ext.declarative import declarative_base
from platypush.context import get_plugin from platypush.context import get_plugin
Base = declarative_base() Base = declarative_base()
Session = scoped_session(sessionmaker())
class UserManager: class UserManager:
@ -41,6 +40,10 @@ class UserManager:
session = self._get_db_session() session = self._get_db_session()
return session.query(User).count() 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): def create_user(self, username, password, **kwargs):
session = self._get_db_session() session = self._get_db_session()
if not username: if not username:
@ -83,19 +86,23 @@ class UserManager:
if not user_session or ( if not user_session or (
user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()): 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() user = session.query(User).filter_by(user_id=user_session.user_id).first()
# Hide password # Hide password
user.password = None user.password = None
return user return user, session
def delete_user(self, username): def delete_user(self, username):
session = self._get_db_session() session = self._get_db_session()
user = self._get_user(session, username) user = self._get_user(session, username)
if not user: 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.delete(user)
session.commit() session.commit()
@ -125,11 +132,11 @@ class UserManager:
user = self._get_user(session, username) user = self._get_user(session, username)
user_session = UserSession(user_id=user.user_id, session_token=self._generate_token(), 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.add(user_session)
session.commit() session.commit()
return user_session return user_session
@staticmethod @staticmethod
@ -151,9 +158,9 @@ class UserManager:
def _get_db_session(self): def _get_db_session(self):
Base.metadata.create_all(self._engine) Base.metadata.create_all(self._engine)
Session = scoped_session(sessionmaker()) session = scoped_session(sessionmaker())
Session.configure(bind=self._engine) session.configure(bind=self._engine)
return Session() return session()
def _authenticate_user(self, session, username, password): def _authenticate_user(self, session, username, password):
user = self._get_user(session, username) user = self._get_user(session, username)
@ -183,6 +190,7 @@ class UserSession(Base):
session_id = Column(Integer, primary_key=True) session_id = Column(Integer, primary_key=True)
session_token = Column(String, unique=True, nullable=False) session_token = Column(String, unique=True, nullable=False)
csrf_token = Column(String, unique=True)
user_id = Column(Integer, ForeignKey('user.user_id'), nullable=False) user_id = Column(Integer, ForeignKey('user.user_id'), nullable=False)
created_at = Column(DateTime) created_at = Column(DateTime)
expires_at = Column(DateTime) expires_at = Column(DateTime)