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 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)

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')
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)

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')
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)

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 {
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,8 +46,20 @@ nav {
}
}
&:hover {
.decorator {
display: none;
}
li.selected {
border-radius: 2rem;
}
}
}
.date-time {
position: absolute;
width: 25%;
display: inline-block;
right: 0;
margin-right: .7rem;
@ -59,7 +73,6 @@ nav {
.settings {
display: inline-block;
cursor: pointer;
padding: .75rem 1rem;
&:hover {
@ -67,28 +80,5 @@ nav {
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;
}
li.selected {
border-radius: 2rem;
}
}
}
}

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', {
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();
},

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>
</li>
{% endfor %}
</ul>
<div class="date-time pull-right">
<div class="settings">
<a href="/settings" class="settings">
<i class="fas fa-cog"></i>
</div>
</a>
<div class="time" v-text="now.toTimeString().substring(0,8)"></div>
</div>
</ul>
</nav>
{% 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()
@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'

View file

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