Support for multi-users and authentication for HTTP pages

This commit is contained in:
Fabio Manganiello 2019-07-15 14:12:00 +02:00
parent 674c164fc1
commit 1c1ecc18df
21 changed files with 669 additions and 175 deletions

View file

@ -209,6 +209,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
'cv2', 'cv2',
'nfc', 'nfc',
'ndef', 'ndef',
'bcrypt',
] ]
sys.path.insert(0, os.path.abspath('../..')) sys.path.insert(0, os.path.abspath('../..'))

View file

@ -1,8 +1,7 @@
from flask import Blueprint, request, render_template from flask import Blueprint, request, render_template
from platypush.backend.http.app import template_folder, static_folder from platypush.backend.http.app import template_folder, static_folder
from platypush.backend.http.app.utils import authenticate, authentication_ok, \ from platypush.backend.http.app.utils import authenticate, get_websocket_port
get_websocket_port
from platypush.backend.http.utils import HttpUtils from platypush.backend.http.utils import HttpUtils
from platypush.config import Config from platypush.config import Config
@ -16,11 +15,9 @@ __routes__ = [
@dashboard.route('/dashboard', methods=['GET']) @dashboard.route('/dashboard', methods=['GET'])
@authenticate()
def dashboard(): def dashboard():
""" Route for the fullscreen dashboard """ """ Route for the fullscreen dashboard """
if not authentication_ok(request):
return authenticate()
http_conf = Config.get('backend.http') http_conf = Config.get('backend.http')
dashboard_conf = http_conf.get('dashboard', {}) dashboard_conf = http_conf.get('dashboard', {})

View file

@ -3,11 +3,7 @@ import json
from flask import Blueprint, abort, request, Response from flask import Blueprint, abort, request, Response
from platypush.backend.http.app import template_folder from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import authenticate, authentication_ok, \ from platypush.backend.http.app.utils import authenticate, logger, send_message
logger, send_message
from platypush.backend.http.utils import HttpUtils
execute = Blueprint('execute', __name__, template_folder=template_folder) execute = Blueprint('execute', __name__, template_folder=template_folder)
@ -16,17 +12,16 @@ __routes__ = [
execute, execute,
] ]
@execute.route('/execute', methods=['POST']) @execute.route('/execute', methods=['POST'])
@authenticate(skip_auth_methods=['session'])
def execute(): def execute():
""" Endpoint to execute commands """ """ Endpoint to execute commands """
if not authentication_ok(request): return authenticate()
try: try:
msg = json.loads(request.data.decode('utf-8')) msg = json.loads(request.data.decode('utf-8'))
except Exception as e: except Exception as e:
logger().error('Unable to parse JSON from request {}: {}'.format( logger().error('Unable to parse JSON from request {}: {}'.format(request.data, str(e)))
request.data, str(e))) return abort(400, str(e))
abort(400, str(e))
logger().info('Received message on the HTTP backend: {}'.format(msg)) logger().info('Received message on the HTTP backend: {}'.format(msg))
@ -34,9 +29,8 @@ def execute():
response = send_message(msg) response = send_message(msg)
return Response(str(response or {}), mimetype='application/json') return Response(str(response or {}), mimetype='application/json')
except Exception as e: except Exception as e:
logger().error('Error while running HTTP action: {}. Request: {}'. logger().error('Error while running HTTP action: {}. Request: {}'.format(str(e), msg))
format(str(e), msg)) return abort(500, str(e))
abort(500, str(e))
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -3,8 +3,7 @@ import json
from flask import Blueprint, abort, request, Response from flask import Blueprint, abort, request, Response
from platypush.backend.http.app import template_folder from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import authenticate, authentication_ok, \ from platypush.backend.http.app.utils import authenticate, logger, send_message
logger, send_message
from platypush.message.event.http.hook import WebhookEvent from platypush.message.event.http.hook import WebhookEvent
@ -16,10 +15,11 @@ __routes__ = [
hook, hook,
] ]
@hook.route('/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) @hook.route('/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
@authenticate(skip_auth_methods=['session'])
def hook(hook_name): def hook(hook_name):
""" Endpoint for custom webhooks """ """ Endpoint for custom webhooks """
if not authentication_ok(request): return authenticate()
event_args = { event_args = {
'hook': hook_name, 'hook': hook_name,

View file

@ -1,10 +1,9 @@
import os import os
from flask import Blueprint, request, render_template from flask import Blueprint, render_template
from platypush.backend.http.app import template_folder, static_folder from platypush.backend.http.app import template_folder, static_folder
from platypush.backend.http.app.utils import authenticate, authentication_ok, \ from platypush.backend.http.app.utils import authenticate, get_websocket_port
get_websocket_port
from platypush.backend.http.utils import HttpUtils from platypush.backend.http.utils import HttpUtils
from platypush.config import Config from platypush.config import Config
@ -19,11 +18,9 @@ __routes__ = [
@index.route('/') @index.route('/')
@authenticate()
def index(): def index():
""" Route to the main web panel """ """ Route to the main web panel """
if not authentication_ok(request):
return authenticate()
configured_plugins = Config.get_plugins() configured_plugins = Config.get_plugins()
enabled_templates = {} enabled_templates = {}
enabled_scripts = {} enabled_scripts = {}

View file

@ -0,0 +1,46 @@
import datetime
from flask import Blueprint, request, redirect, render_template, make_response, url_for
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
login = Blueprint('login', __name__, template_folder=template_folder)
# Declare routes list
__routes__ = [
login,
]
@login.route('/login', methods=['GET', 'POST'])
def login():
""" Login page """
user_manager = UserManager()
redirect_page = request.args.get('redirect', '/')
session_token = request.cookies.get('session_token')
if session_token:
user = user_manager.authenticate_user_session(session_token)
if user:
return redirect(redirect_page, 302)
if request.form:
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember')
session = user_manager.create_user_session(username=username, password=password,
expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=1)
if not remember else None)
if session:
redirect_target = redirect(redirect_page, 302)
response = make_response(redirect_target)
response.set_cookie('session_token', session.session_token)
return response
return render_template('login.html', utils=HttpUtils)
# vim:sw=4:ts=4:et:

View file

@ -1,15 +1,12 @@
import json
import os import os
import shutil import shutil
import tempfile import tempfile
import time import time
from flask import abort, jsonify, request, Response, Blueprint, \ from flask import Response, Blueprint, send_from_directory
send_from_directory
from platypush.backend.http.app import template_folder from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import logger, get_remote_base_url, \ from platypush.backend.http.app.utils import authenticate, send_request
authenticate_user, send_request
camera = Blueprint('camera', __name__, template_folder=template_folder) camera = Blueprint('camera', __name__, template_folder=template_folder)
@ -18,6 +15,7 @@ __routes__ = [
camera, camera,
] ]
def get_device_id(device_id=None): def get_device_id(device_id=None):
if device_id is None: if device_id is None:
device_id = str(send_request(action='camera.get_default_device_id').output) device_id = str(send_request(action='camera.get_default_device_id').output)
@ -32,8 +30,8 @@ def get_frame_file(device_id=None):
if device_id not in status: if device_id not in status:
was_recording = False was_recording = False
response = send_request(action='camera.start_recording', send_request(action='camera.start_recording',
device_id=device_id) device_id=device_id)
while not frame_file: while not frame_file:
frame_file = send_request(action='camera.status', device_id=device_id). \ frame_file = send_request(action='camera.status', device_id=device_id). \
@ -43,12 +41,10 @@ def get_frame_file(device_id=None):
time.sleep(0.1) time.sleep(0.1)
if not was_recording: if not was_recording:
# stop_recording will delete the temporary frames. Copy the image file
# to a temporary file before stopping recording
tmp_file = None
with tempfile.NamedTemporaryFile(prefix='camera_capture_', suffix='.jpg', with tempfile.NamedTemporaryFile(prefix='camera_capture_', suffix='.jpg',
delete=False) as f: delete=False) as f:
# stop_recording will delete the temporary frames. Copy the image file
# to a temporary file before stopping recording
tmp_file = f.name tmp_file = f.name
shutil.copyfile(frame_file, tmp_file) shutil.copyfile(frame_file, tmp_file)
@ -62,7 +58,6 @@ def video_feed(device_id=None):
device_id = get_device_id(device_id) device_id = get_device_id(device_id)
send_request(action='camera.start_recording', device_id=device_id) send_request(action='camera.start_recording', device_id=device_id)
last_frame_file = None last_frame_file = None
last_frame = None
try: try:
while True: while True:
@ -77,33 +72,35 @@ def video_feed(device_id=None):
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
last_frame_file = frame_file last_frame_file = frame_file
last_frame = frame
finally: finally:
send_request(action='camera.stop_recording', device_id=device_id) send_request(action='camera.stop_recording', device_id=device_id)
@camera.route('/camera/<device_id>/frame', methods=['GET']) @camera.route('/camera/<device_id>/frame', methods=['GET'])
@authenticate_user @authenticate()
def get_camera_frame(device_id): def get_camera_frame(device_id):
frame_file = get_frame_file(device_id) frame_file = get_frame_file(device_id)
return send_from_directory(os.path.dirname(frame_file), return send_from_directory(os.path.dirname(frame_file),
os.path.basename(frame_file)) os.path.basename(frame_file))
@camera.route('/camera/frame', methods=['GET']) @camera.route('/camera/frame', methods=['GET'])
@authenticate_user @authenticate()
def get_default_camera_frame(): def get_default_camera_frame():
frame_file = get_frame_file() frame_file = get_frame_file()
return send_from_directory(os.path.dirname(frame_file), return send_from_directory(os.path.dirname(frame_file),
os.path.basename(frame_file)) os.path.basename(frame_file))
@camera.route('/camera/stream', methods=['GET']) @camera.route('/camera/stream', methods=['GET'])
@authenticate_user @authenticate()
def get_default_stream_feed(): def get_default_stream_feed():
return Response(video_feed(), return Response(video_feed(),
mimetype='multipart/x-mixed-replace; boundary=frame') mimetype='multipart/x-mixed-replace; boundary=frame')
@camera.route('/camera/<device_id>/stream', methods=['GET']) @camera.route('/camera/<device_id>/stream', methods=['GET'])
@authenticate_user @authenticate()
def get_stream_feed(device_id): def get_stream_feed(device_id):
return Response(video_feed(device_id), return Response(video_feed(device_id),
mimetype='multipart/x-mixed-replace; boundary=frame') mimetype='multipart/x-mixed-replace; boundary=frame')

View file

@ -0,0 +1,53 @@
import datetime
from flask import Blueprint, request, redirect, render_template, make_response, url_for
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
register = Blueprint('register', __name__, template_folder=template_folder)
# Declare routes list
__routes__ = [
register,
]
@register.route('/register', methods=['GET', 'POST'])
def register():
""" Registration page """
user_manager = UserManager()
redirect_page = request.args.get('redirect', '/')
session_token = request.cookies.get('session_token')
if session_token:
user = user_manager.authenticate_user_session(session_token)
if user:
return redirect(redirect_page, 302)
if request.form:
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
remember = request.form.get('remember')
if password == confirm_password:
user_manager.create_user(username=username, password=password)
session = user_manager.create_user_session(username=username, password=password,
expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=1)
if not remember else None)
if session:
redirect_target = redirect(redirect_page, 302)
response = make_response(redirect_target)
response.set_cookie('session_token', session.session_token)
return response
if user_manager.get_user_count() > 0:
return redirect('/login?redirect=' + redirect_page, 302)
return render_template('register.html', utils=HttpUtils)
# vim:sw=4:ts=4:et:

View file

@ -5,8 +5,6 @@ from flask import Blueprint, abort, send_from_directory
from platypush.config import Config from platypush.config import Config
from platypush.backend.http.app import template_folder, static_folder from platypush.backend.http.app import template_folder, static_folder
from platypush.backend.http.app.utils import authenticate, authentication_ok, \
send_message
img_folder = os.path.join(static_folder, 'resources', 'img') img_folder = os.path.join(static_folder, 'resources', 'img')
@ -21,6 +19,7 @@ __routes__ = [
img, img,
] ]
@resources.route('/resources/<path:path>', methods=['GET']) @resources.route('/resources/<path:path>', methods=['GET'])
def resources_path(path): def resources_path(path):
""" Custom static resources """ """ Custom static resources """

View file

@ -2,10 +2,9 @@ import importlib
import json import json
import logging import logging
import os import os
import sys
from functools import wraps from functools import wraps
from flask import request, Response from flask import request, redirect, Response
from redis import Redis from redis import Redis
# NOTE: The HTTP service will *only* work on top of a Redis bus. The default # NOTE: The HTTP service will *only* work on top of a Redis bus. The default
@ -15,6 +14,7 @@ from platypush.bus.redis import RedisBus
from platypush.config import Config from platypush.config import Config
from platypush.message import Message from platypush.message import Message
from platypush.message.request import Request from platypush.message.request import Request
from platypush.user import UserManager
from platypush.utils import get_redis_queue_name_by_message, get_ip_or_hostname from platypush.utils import get_redis_queue_name_by_message, get_ip_or_hostname
_bus = None _bus = None
@ -27,6 +27,7 @@ def bus():
_bus = RedisBus() _bus = RedisBus()
return _bus return _bus
def logger(): def logger():
global _logger global _logger
if not _logger: if not _logger:
@ -50,6 +51,7 @@ def logger():
return _logger return _logger
def get_message_response(msg): def get_message_response(msg):
redis = Redis(**bus().redis_args) redis = Redis(**bus().redis_args)
response = redis.blpop(get_redis_queue_name_by_message(msg), timeout=60) response = redis.blpop(get_redis_queue_name_by_message(msg), timeout=60)
@ -60,16 +62,21 @@ def get_message_response(msg):
return response return response
# noinspection PyProtectedMember
def get_http_port(): def get_http_port():
from platypush.backend.http import HttpBackend from platypush.backend.http import HttpBackend
http_conf = Config.get('backend.http') http_conf = Config.get('backend.http')
return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT) return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT)
# noinspection PyProtectedMember
def get_websocket_port(): def get_websocket_port():
from platypush.backend.http import HttpBackend from platypush.backend.http import HttpBackend
http_conf = Config.get('backend.http') http_conf = Config.get('backend.http')
return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT) return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT)
def send_message(msg): def send_message(msg):
msg = Message.build(msg) msg = Message.build(msg)
@ -88,6 +95,7 @@ def send_message(msg):
return response return response
def send_request(action, **kwargs): def send_request(action, **kwargs):
msg = { msg = {
'type': 'request', 'type': 'request',
@ -99,36 +107,97 @@ def send_request(action, **kwargs):
return send_message(msg) return send_message(msg)
def authenticate():
return Response('Authentication required', 401,
{'WWW-Authenticate': 'Basic realm="Login required"'})
def authentication_ok(req): def _authenticate_token():
token = Config.get('token') token = Config.get('token')
if not token:
return True
user_token = None user_token = None
# Check if if 'X-Token' in request.headers:
if 'X-Token' in req.headers: user_token = request.headers['X-Token']
user_token = req.headers['X-Token'] elif 'token' in request.args:
elif req.authorization: user_token = request.args.get('token')
# TODO support for user check
user_token = req.authorization.password
elif 'token' in req.args:
user_token = req.args.get('token')
else: else:
try: return False
args = json.loads(req.data.decode('utf-8'))
user_token = args.get('token')
except:
pass
if user_token == token: return token and user_token == token
return True
def _authenticate_http():
user_manager = UserManager()
if not request.authorization:
return False
username = request.authorization.username
password = request.authorization.password
return user_manager.authenticate_user(username, password)
def _authenticate_session():
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 = user_manager.authenticate_user_session(user_session_token)
return user is not None
def authenticate(redirect_page='', skip_auth_methods=None):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
user_manager = UserManager()
n_users = user_manager.get_user_count()
token = Config.get('token')
skip_methods = skip_auth_methods or []
# User/pass HTTP authentication
http_auth_ok = True
if n_users > 0 and 'http' not in skip_methods:
http_auth_ok = _authenticate_http()
if http_auth_ok:
return f(*args, **kwargs)
# Token-based authentication
token_auth_ok = True
if token and 'token' not in skip_methods:
token_auth_ok = _authenticate_token()
if token_auth_ok:
return f(*args, **kwargs)
# Session token based authentication
session_auth_ok = True
if n_users > 0 and 'session' not in skip_methods:
session_auth_ok = _authenticate_session()
if session_auth_ok:
return f(*args, **kwargs)
return redirect('/login?redirect=' + redirect_page, 307)
if n_users == 0 and 'session' not in skip_methods:
return redirect('/register?redirect=' + redirect_page, 307)
if ('http' not in skip_methods and http_auth_ok) or \
('token' not in skip_methods and token_auth_ok) or \
('session' not in skip_methods and session_auth_ok):
return f(*args, **kwargs)
return Response('Authentication required', 401,
{'WWW-Authenticate': 'Basic realm="Login required"'})
return wrapper
return decorator
return False
def get_routes(): def get_routes():
routes_dir = os.path.join( routes_dir = os.path.join(
@ -141,8 +210,7 @@ def get_routes():
if f.endswith('.py'): if f.endswith('.py'):
mod_name = '.'.join( mod_name = '.'.join(
(base_module + '.' + os.path.join(path, f).replace( (base_module + '.' + os.path.join(path, f).replace(
os.path.dirname(__file__), '')[1:] \ os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.')
.replace(os.sep, '.')).split('.') \
[:(-2 if f == '__init__.py' else -1)]) [:(-2 if f == '__init__.py' else -1)])
try: try:
@ -162,6 +230,7 @@ def get_local_base_url():
proto=('https' if http_conf.get('ssl_cert') else 'http'), proto=('https' if http_conf.get('ssl_cert') else 'http'),
port=get_http_port()) port=get_http_port())
def get_remote_base_url(): def get_remote_base_url():
http_conf = Config.get('backend.http') or {} http_conf = Config.get('backend.http') or {}
return '{proto}://{host}:{port}'.format( return '{proto}://{host}:{port}'.format(
@ -169,12 +238,4 @@ def get_remote_base_url():
host=get_ip_or_hostname(), port=get_http_port()) host=get_ip_or_hostname(), port=get_http_port())
def authenticate_user(route):
@wraps(route)
def authenticated_route(*args, **kwargs):
if not authentication_ok(request): return authenticate()
return route(*args, **kwargs)
return authenticated_route
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -7,7 +7,8 @@
color: $text-icon-color; color: $text-icon-color;
} }
input[type=text] { input[type=text],
input[type=password] {
border-radius: 5rem; border-radius: 5rem;
&:hover { &:hover {

View file

@ -0,0 +1,46 @@
@import 'common/vars';
@import 'common/mixins';
@import 'common/layout';
@import 'common/elements';
body {
width: 100vw;
height: 100vh;
margin: 0;
overflow-x: hidden;
font-family: $default-font-family;
font-size: $default-font-size;
}
main {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
form {
border: $default-border-3;
border-radius: 3em;
padding: 4em;
.row {
margin: 1em 0;
}
input[type=text],
input[type=password] {
width: 100%;
}
input[type=submit] {
border-radius: 1em;
}
}
}
a {
color: $default-link-fg;
}

View file

@ -0,0 +1 @@
login

View file

@ -1,46 +0,0 @@
$(document).ready(function() {
var onEvent = function(event) {
if (event.args.type == 'platypush.message.event.web.widget.WidgetUpdateEvent') {
var $widget = $('#' + event.args.widget);
delete event.args.widget;
for (var key of Object.keys(event.args)) {
$widget.find('[data-bind=' + key + ']').text(event.args[key]);
}
} else if (event.args.type == 'platypush.message.event.web.DashboardIframeUpdateEvent') {
var url = event.args.url;
var $modal = $('#iframe-modal');
var $iframe = $modal.find('iframe');
$iframe.attr('src', url);
$iframe.prop('width', event.args.width || '100%');
$iframe.prop('height', event.args.height || '600');
if ('timeout' in event.args) {
setTimeout(function() {
$iframe.removeAttr('src');
$modal.fadeOut();
}, parseFloat(event.args.timeout) * 1000);
}
$modal.fadeIn();
}
};
var initDashboard = function() {
if ('background_image' in window.config) {
$('body').css('background-image', 'url(' + window.config.background_image + ')');
}
};
var initEvents = function() {
window.registerEventListener(onEvent);
};
var init = function() {
initDashboard();
initEvents();
};
init();
});

View file

@ -0,0 +1,29 @@
<!doctype html>
<head>
<title>Platypush login page</title>
<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/login.css') }}">
</head>
<body>
<main>
<form class="login" method="POST">
<div class="row">
<input type="text" name="username" placeholder="Username">
</div>
<div class="row">
<input type="password" name="password" placeholder="Password">
</div>
<div class="row pull-right">
<input type="submit" class="btn btn-primary" value="Login">
</div>
<div class="row pull-right">
Keep me logged in on this device &nbsp;<input type="checkbox" name="remember">
</div>
</form>
</main>
</body>

View file

@ -1,46 +0,0 @@
<!doctype html>
<head>
<title>Platypush map service</title>
<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/font-awesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/application.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/toggles.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/map.css') }}">
<link href='//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700' rel='stylesheet' type='text/css'>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery-3.3.1.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/application.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/map.js') }}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ api_key }}&v=3.exp&callback=initMap"></script>
<script type="text/javascript">
window.db_conf = JSON.parse('{{ utils.to_json(config["db"]) | safe }}');
window.websocket_port = {{ websocket_port }};
window.has_ssl = {% print('true' if has_ssl else 'false') %};
window.map_start = "{{ start }}";
window.map_end = "{{ end }}";
{% if zoom %}
window.zoom = {{ zoom }};
{% else %}
window.zoom = undefined;
{% endif %}
{% if token %}
window.token = '{{ token }}'
{% else %}
window.token = undefined
{% endif %}
</script>
</head>
<body>
<main>
<div id="map-container" width="100" height="100">
<div id="map"></div>
</div>
</main>
<body>

View file

@ -0,0 +1,32 @@
<!doctype html>
<head>
<title>Platypush registration page</title>
<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/login.css') }}">
</head>
<body>
<main>
<form class="register" method="POST">
<div class="row">
<input type="text" name="username" placeholder="New username">
</div>
<div class="row">
<input type="password" name="password" placeholder="Password">
</div>
<div class="row">
<input type="password" name="confirm_password" placeholder="Confirm password">
</div>
<div class="row pull-right">
<input type="submit" class="btn btn-primary" value="Register">
</div>
<div class="row pull-right">
Keep me logged in on this device &nbsp;<input type="checkbox" name="remember">
</div>
</form>
</main>
</body>

View file

@ -43,7 +43,7 @@ class DbPlugin(Plugin):
elif not isinstance(value, int) and not isinstance(value, float): elif not isinstance(value, int) and not isinstance(value, float):
value = "'{}'".format(str(value)) value = "'{}'".format(str(value))
return eval('{}.{}=={}'.format(table, column, value)) return eval('table.c.{}=={}'.format(column, value))
@action @action
def execute(self, statement, engine=None, *args, **kwargs): def execute(self, statement, engine=None, *args, **kwargs):

138
platypush/plugins/user.py Normal file
View file

@ -0,0 +1,138 @@
from platypush.plugins import Plugin, action
from platypush.user import UserManager
class UserPlugin(Plugin):
"""
Plugin to programmatically create and manage users and user sessions
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.user_manager = UserManager()
@action
def create_user(self, username, password, executing_user=None, executing_user_password=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.
:return: dict::
{
"user_id": int,
"username": str,
"created_at": str (in ISO format)
}
"""
if self.user_manager.get_user_count() > 0 and not executing_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):
return None, "Invalid credentials"
try:
user = self.user_manager.create_user(username, password, **kwargs)
except (NameError, ValueError) as e:
return None, str(e)
return {
'user_id': user.user_id,
'username': user.username,
'created_at': user.created_at.isoformat(),
}
@action
def authenticate_user(self, username, password):
"""
Authenticate a user
:return: True if the provided username and password are correct, False otherwise
"""
return self.user_manager.authenticate_user(username, password)
@action
def update_password(self, username, old_password, new_password):
"""
Update the password of a user
:return: True if the password was successfully updated, false otherwise
"""
return self.user_manager.update_password(username, old_password, new_password)
@action
def delete_user(self, username, executing_user, executing_user_password):
"""
Delete a user
"""
if not self.user_manager.authenticate_user(executing_user, executing_user_password):
return None, "Invalid credentials"
try:
return self.user_manager.delete_user(username)
except NameError:
return None, "No such user: {}".format(username)
@action
def create_session(self, username, password, expires_at=None):
"""
Create a user session
:return: dict::
{
"session_token": str,
"user_id": int,
"created_at": str (in ISO format),
"expires_at": str (in ISO format),
}
"""
session = self.user_manager.create_user_session(username=username,
password=password,
expires_at=expires_at)
if not session:
return None, "Invalid credentials"
return {
'session_token': session.session_token,
'user_id': session.user_id,
'created_at': session.created_at.isoformat(),
'expires_at': session.expires_at.isoformat() if session.expires_at else None,
}
@action
def authenticate_session(self, session_token):
"""
Authenticate a session by token and return the associated user
:return: dict::
{
"user_id": int,
"username": str,
"created_at": str (in ISO format)
}
"""
user = self.user_manager.authenticate_user_session(session_token=session_token)
if not user:
return None, 'Invalid session token'
return {
'user_id': user.user_id,
'username': user.username,
'created_at': user.created_at.isoformat(),
}
@action
def delete_session(self, session_token):
"""
Delete a user session
"""
return self.user_manager.delete_user_session(session_token)
# vim:sw=4:ts=4:et:

191
platypush/user/__init__.py Normal file
View file

@ -0,0 +1,191 @@
import datetime
import hashlib
import random
import bcrypt
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from platypush.context import get_plugin
Base = declarative_base()
Session = scoped_session(sessionmaker())
class UserManager:
"""
Main class for managing platform users
"""
# noinspection PyProtectedMember
def __init__(self):
db_plugin = get_plugin('db')
if not db_plugin:
raise ModuleNotFoundError('Please enable/configure the db plugin for multi-user support')
self._engine = db_plugin._get_engine()
def get_user(self, username):
session = self._get_db_session()
user = self._get_user(session, username)
if not user:
return None
# Hide password
user.password = None
return user
def get_user_count(self):
session = self._get_db_session()
return session.query(User).count()
def create_user(self, username, password, **kwargs):
session = self._get_db_session()
if not username:
raise ValueError('Invalid or empty username')
if not password:
raise ValueError('Please provide a password for the user')
user = self._get_user(session, username)
if user:
raise NameError('The user {} already exists'.format(username))
record = User(username=username, password=self._encrypt_password(password),
created_at=datetime.datetime.utcnow(), **kwargs)
session.add(record)
session.commit()
user = self._get_user(session, username)
# Hide password
user.password = None
return user
def update_password(self, username, old_password, new_password):
session = self._get_db_session()
if not self._authenticate_user(session, username, old_password):
return False
user = self._get_user(session, username)
user.password = self._encrypt_password(new_password)
session.commit()
return True
def authenticate_user(self, username, password):
session = self._get_db_session()
return self._authenticate_user(session, username, password)
def authenticate_user_session(self, session_token):
session = self._get_db_session()
user_session = session.query(UserSession).filter_by(session_token=session_token).first()
if not user_session or (
user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()):
return None
user = session.query(User).filter_by(user_id=user_session.user_id).first()
# Hide password
user.password = None
return user
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))
session.delete(user)
session.commit()
return True
def delete_user_session(self, session_token):
session = self._get_db_session()
user_session = session.query(UserSession).filter_by(session_token=session_token).first()
if not user_session:
return False
session.delete(user_session)
session.commit()
return True
def create_user_session(self, username, password, expires_at=None):
session = self._get_db_session()
if not self._authenticate_user(session, username, password):
return None
if expires_at:
if isinstance(expires_at, int) or isinstance(expires_at, float):
expires_at = datetime.datetime.fromtimestamp(expires_at)
elif isinstance(expires_at, str):
expires_at = datetime.datetime.fromisoformat(expires_at)
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)
session.add(user_session)
session.commit()
return user_session
@staticmethod
def _get_user(session, username):
return session.query(User).filter_by(username=username).first()
@staticmethod
def _encrypt_password(pwd):
return bcrypt.hashpw(pwd.encode(), bcrypt.gensalt(12))
@staticmethod
def _check_password(pwd, hashed_pwd):
return bcrypt.checkpw(pwd.encode(), hashed_pwd)
@staticmethod
def _generate_token():
rand = bytes(random.randint(0, 255) for _ in range(0, 255))
return hashlib.sha256(rand).hexdigest()
def _get_db_session(self):
Base.metadata.create_all(self._engine)
Session = scoped_session(sessionmaker())
Session.configure(bind=self._engine)
return Session()
def _authenticate_user(self, session, username, password):
user = self._get_user(session, username)
if not user:
return False
return self._check_password(password, user.password)
class User(Base):
""" Models the User table """
__tablename__ = 'user'
__table_args__ = ({'sqlite_autoincrement': True})
user_id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False)
password = Column(String)
created_at = Column(DateTime)
class UserSession(Base):
""" Models the UserSession table """
__tablename__ = 'user_session'
__table_args__ = ({'sqlite_autoincrement': True})
session_id = Column(Integer, primary_key=True)
session_token = Column(String, unique=True, nullable=False)
user_id = Column(Integer, ForeignKey('user.user_id'), nullable=False)
created_at = Column(DateTime)
expires_at = Column(DateTime)
# vim:sw=4:ts=4:et:

View file

@ -32,6 +32,9 @@ requests
# Database plugin support # Database plugin support
sqlalchemy sqlalchemy
# Support for multi-users and password authentication
bcrypt
# RSS feeds support # RSS feeds support
# feedparser # feedparser