From 1c1ecc18dfd4e0fa06618ab48081515e5af9d08f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 15 Jul 2019 14:12:00 +0200 Subject: [PATCH] Support for multi-users and authentication for HTTP pages --- docs/source/conf.py | 1 + .../backend/http/app/routes/dashboard.py | 7 +- platypush/backend/http/app/routes/execute.py | 20 +- platypush/backend/http/app/routes/hook.py | 6 +- platypush/backend/http/app/routes/index.py | 9 +- platypush/backend/http/app/routes/login.py | 46 +++++ .../app/routes/plugins/camera/__init__.py | 31 ++- platypush/backend/http/app/routes/register.py | 53 +++++ .../backend/http/app/routes/resources.py | 3 +- platypush/backend/http/app/utils.py | 131 ++++++++---- .../css/source/common/elements/text.scss | 3 +- .../http/static/css/source/login/index.scss | 46 +++++ .../backend/http/static/css/source/register | 1 + .../backend/http/static/js/dashboard_old.js | 46 ----- platypush/backend/http/templates/login.html | 29 +++ platypush/backend/http/templates/map.html | 46 ----- .../backend/http/templates/register.html | 32 +++ platypush/plugins/db/__init__.py | 2 +- platypush/plugins/user.py | 138 +++++++++++++ platypush/user/__init__.py | 191 ++++++++++++++++++ requirements.txt | 3 + 21 files changed, 669 insertions(+), 175 deletions(-) create mode 100644 platypush/backend/http/app/routes/login.py create mode 100644 platypush/backend/http/app/routes/register.py create mode 100644 platypush/backend/http/static/css/source/login/index.scss create mode 120000 platypush/backend/http/static/css/source/register delete mode 100644 platypush/backend/http/static/js/dashboard_old.js create mode 100644 platypush/backend/http/templates/login.html delete mode 100644 platypush/backend/http/templates/map.html create mode 100644 platypush/backend/http/templates/register.html create mode 100644 platypush/plugins/user.py create mode 100644 platypush/user/__init__.py diff --git a/docs/source/conf.py b/docs/source/conf.py index bb1879e75..508b6c0df 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -209,6 +209,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'cv2', 'nfc', 'ndef', + 'bcrypt', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/http/app/routes/dashboard.py b/platypush/backend/http/app/routes/dashboard.py index 7340ddf8e..b7dcb2dfb 100644 --- a/platypush/backend/http/app/routes/dashboard.py +++ b/platypush/backend/http/app/routes/dashboard.py @@ -1,8 +1,7 @@ from flask import Blueprint, request, render_template from platypush.backend.http.app import template_folder, static_folder -from platypush.backend.http.app.utils import authenticate, authentication_ok, \ - get_websocket_port +from platypush.backend.http.app.utils import authenticate, get_websocket_port from platypush.backend.http.utils import HttpUtils from platypush.config import Config @@ -16,11 +15,9 @@ __routes__ = [ @dashboard.route('/dashboard', methods=['GET']) +@authenticate() def dashboard(): """ Route for the fullscreen dashboard """ - if not authentication_ok(request): - return authenticate() - http_conf = Config.get('backend.http') dashboard_conf = http_conf.get('dashboard', {}) diff --git a/platypush/backend/http/app/routes/execute.py b/platypush/backend/http/app/routes/execute.py index 4887dc01b..59fda1730 100644 --- a/platypush/backend/http/app/routes/execute.py +++ b/platypush/backend/http/app/routes/execute.py @@ -3,11 +3,7 @@ import json from flask import Blueprint, abort, request, Response from platypush.backend.http.app import template_folder -from platypush.backend.http.app.utils import authenticate, authentication_ok, \ - logger, send_message - -from platypush.backend.http.utils import HttpUtils - +from platypush.backend.http.app.utils import authenticate, logger, send_message execute = Blueprint('execute', __name__, template_folder=template_folder) @@ -16,17 +12,16 @@ __routes__ = [ execute, ] + @execute.route('/execute', methods=['POST']) +@authenticate(skip_auth_methods=['session']) def execute(): """ Endpoint to execute commands """ - if not authentication_ok(request): return authenticate() - try: msg = json.loads(request.data.decode('utf-8')) except Exception as e: - logger().error('Unable to parse JSON from request {}: {}'.format( - request.data, str(e))) - abort(400, str(e)) + logger().error('Unable to parse JSON from request {}: {}'.format(request.data, str(e))) + return abort(400, str(e)) logger().info('Received message on the HTTP backend: {}'.format(msg)) @@ -34,9 +29,8 @@ def execute(): response = send_message(msg) return Response(str(response or {}), mimetype='application/json') except Exception as e: - logger().error('Error while running HTTP action: {}. Request: {}'. - format(str(e), msg)) - abort(500, str(e)) + logger().error('Error while running HTTP action: {}. Request: {}'.format(str(e), msg)) + return abort(500, str(e)) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/app/routes/hook.py b/platypush/backend/http/app/routes/hook.py index c7e4e1972..e4e6954b5 100644 --- a/platypush/backend/http/app/routes/hook.py +++ b/platypush/backend/http/app/routes/hook.py @@ -3,8 +3,7 @@ import json from flask import Blueprint, abort, request, Response from platypush.backend.http.app import template_folder -from platypush.backend.http.app.utils import authenticate, authentication_ok, \ - logger, send_message +from platypush.backend.http.app.utils import authenticate, logger, send_message from platypush.message.event.http.hook import WebhookEvent @@ -16,10 +15,11 @@ __routes__ = [ hook, ] + @hook.route('/hook/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) +@authenticate(skip_auth_methods=['session']) def hook(hook_name): """ Endpoint for custom webhooks """ - if not authentication_ok(request): return authenticate() event_args = { 'hook': hook_name, diff --git a/platypush/backend/http/app/routes/index.py b/platypush/backend/http/app/routes/index.py index 3fc343183..db8b440be 100644 --- a/platypush/backend/http/app/routes/index.py +++ b/platypush/backend/http/app/routes/index.py @@ -1,10 +1,9 @@ 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.utils import authenticate, authentication_ok, \ - get_websocket_port +from platypush.backend.http.app.utils import authenticate, get_websocket_port from platypush.backend.http.utils import HttpUtils from platypush.config import Config @@ -19,11 +18,9 @@ __routes__ = [ @index.route('/') +@authenticate() def index(): """ Route to the main web panel """ - if not authentication_ok(request): - return authenticate() - configured_plugins = Config.get_plugins() enabled_templates = {} enabled_scripts = {} diff --git a/platypush/backend/http/app/routes/login.py b/platypush/backend/http/app/routes/login.py new file mode 100644 index 000000000..e1edeb5b6 --- /dev/null +++ b/platypush/backend/http/app/routes/login.py @@ -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: diff --git a/platypush/backend/http/app/routes/plugins/camera/__init__.py b/platypush/backend/http/app/routes/plugins/camera/__init__.py index 10d8bcd83..77663f7bd 100644 --- a/platypush/backend/http/app/routes/plugins/camera/__init__.py +++ b/platypush/backend/http/app/routes/plugins/camera/__init__.py @@ -1,15 +1,12 @@ -import json import os import shutil import tempfile import time -from flask import abort, jsonify, request, Response, Blueprint, \ - send_from_directory +from flask import Response, Blueprint, send_from_directory from platypush.backend.http.app import template_folder -from platypush.backend.http.app.utils import logger, get_remote_base_url, \ - authenticate_user, send_request +from platypush.backend.http.app.utils import authenticate, send_request camera = Blueprint('camera', __name__, template_folder=template_folder) @@ -18,6 +15,7 @@ __routes__ = [ camera, ] + def get_device_id(device_id=None): if device_id is None: 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: was_recording = False - response = send_request(action='camera.start_recording', - device_id=device_id) + send_request(action='camera.start_recording', + device_id=device_id) while not frame_file: 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) 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', 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 shutil.copyfile(frame_file, tmp_file) @@ -62,7 +58,6 @@ def video_feed(device_id=None): device_id = get_device_id(device_id) send_request(action='camera.start_recording', device_id=device_id) last_frame_file = None - last_frame = None try: 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') last_frame_file = frame_file - last_frame = frame finally: send_request(action='camera.stop_recording', device_id=device_id) @camera.route('/camera//frame', methods=['GET']) -@authenticate_user +@authenticate() def get_camera_frame(device_id): frame_file = get_frame_file(device_id) return send_from_directory(os.path.dirname(frame_file), os.path.basename(frame_file)) + @camera.route('/camera/frame', methods=['GET']) -@authenticate_user +@authenticate() def get_default_camera_frame(): frame_file = get_frame_file() return send_from_directory(os.path.dirname(frame_file), os.path.basename(frame_file)) + @camera.route('/camera/stream', methods=['GET']) -@authenticate_user +@authenticate() def get_default_stream_feed(): return Response(video_feed(), mimetype='multipart/x-mixed-replace; boundary=frame') + @camera.route('/camera//stream', methods=['GET']) -@authenticate_user +@authenticate() def get_stream_feed(device_id): return Response(video_feed(device_id), mimetype='multipart/x-mixed-replace; boundary=frame') diff --git a/platypush/backend/http/app/routes/register.py b/platypush/backend/http/app/routes/register.py new file mode 100644 index 000000000..354c961df --- /dev/null +++ b/platypush/backend/http/app/routes/register.py @@ -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: diff --git a/platypush/backend/http/app/routes/resources.py b/platypush/backend/http/app/routes/resources.py index f2a953d42..3d27237e9 100644 --- a/platypush/backend/http/app/routes/resources.py +++ b/platypush/backend/http/app/routes/resources.py @@ -5,8 +5,6 @@ from flask import Blueprint, abort, send_from_directory from platypush.config import Config 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') @@ -21,6 +19,7 @@ __routes__ = [ img, ] + @resources.route('/resources/', methods=['GET']) def resources_path(path): """ Custom static resources """ diff --git a/platypush/backend/http/app/utils.py b/platypush/backend/http/app/utils.py index 52367c4c5..adaf8ef87 100644 --- a/platypush/backend/http/app/utils.py +++ b/platypush/backend/http/app/utils.py @@ -2,10 +2,9 @@ import importlib import json import logging import os -import sys from functools import wraps -from flask import request, Response +from flask import request, redirect, Response from redis import Redis # 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.message import Message 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 _bus = None @@ -27,6 +27,7 @@ def bus(): _bus = RedisBus() return _bus + def logger(): global _logger if not _logger: @@ -50,6 +51,7 @@ def logger(): return _logger + def get_message_response(msg): redis = Redis(**bus().redis_args) response = redis.blpop(get_redis_queue_name_by_message(msg), timeout=60) @@ -60,16 +62,21 @@ def get_message_response(msg): return response + +# noinspection PyProtectedMember def get_http_port(): from platypush.backend.http import HttpBackend http_conf = Config.get('backend.http') return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT) + +# noinspection PyProtectedMember def get_websocket_port(): from platypush.backend.http import HttpBackend http_conf = Config.get('backend.http') return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT) + def send_message(msg): msg = Message.build(msg) @@ -88,6 +95,7 @@ def send_message(msg): return response + def send_request(action, **kwargs): msg = { 'type': 'request', @@ -99,36 +107,97 @@ def send_request(action, **kwargs): 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') - if not token: - return True - user_token = None - # Check if - if 'X-Token' in req.headers: - user_token = req.headers['X-Token'] - elif req.authorization: - # TODO support for user check - user_token = req.authorization.password - elif 'token' in req.args: - user_token = req.args.get('token') + if 'X-Token' in request.headers: + user_token = request.headers['X-Token'] + elif 'token' in request.args: + user_token = request.args.get('token') else: - try: - args = json.loads(req.data.decode('utf-8')) - user_token = args.get('token') - except: - pass + return False - if user_token == token: - return True + return token and user_token == token + + +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(): routes_dir = os.path.join( @@ -141,8 +210,7 @@ def get_routes(): if f.endswith('.py'): mod_name = '.'.join( (base_module + '.' + os.path.join(path, f).replace( - os.path.dirname(__file__), '')[1:] \ - .replace(os.sep, '.')).split('.') \ + os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.') [:(-2 if f == '__init__.py' else -1)]) try: @@ -162,6 +230,7 @@ def get_local_base_url(): proto=('https' if http_conf.get('ssl_cert') else 'http'), port=get_http_port()) + def get_remote_base_url(): http_conf = Config.get('backend.http') or {} return '{proto}://{host}:{port}'.format( @@ -169,12 +238,4 @@ def get_remote_base_url(): 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: diff --git a/platypush/backend/http/static/css/source/common/elements/text.scss b/platypush/backend/http/static/css/source/common/elements/text.scss index e8bd0f731..f9639a87a 100644 --- a/platypush/backend/http/static/css/source/common/elements/text.scss +++ b/platypush/backend/http/static/css/source/common/elements/text.scss @@ -7,7 +7,8 @@ color: $text-icon-color; } -input[type=text] { +input[type=text], +input[type=password] { border-radius: 5rem; &:hover { diff --git a/platypush/backend/http/static/css/source/login/index.scss b/platypush/backend/http/static/css/source/login/index.scss new file mode 100644 index 000000000..b94d0b795 --- /dev/null +++ b/platypush/backend/http/static/css/source/login/index.scss @@ -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; +} + diff --git a/platypush/backend/http/static/css/source/register b/platypush/backend/http/static/css/source/register new file mode 120000 index 000000000..e9f12b605 --- /dev/null +++ b/platypush/backend/http/static/css/source/register @@ -0,0 +1 @@ +login \ No newline at end of file diff --git a/platypush/backend/http/static/js/dashboard_old.js b/platypush/backend/http/static/js/dashboard_old.js deleted file mode 100644 index 63b262189..000000000 --- a/platypush/backend/http/static/js/dashboard_old.js +++ /dev/null @@ -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(); -}); - diff --git a/platypush/backend/http/templates/login.html b/platypush/backend/http/templates/login.html new file mode 100644 index 000000000..173a5b8cf --- /dev/null +++ b/platypush/backend/http/templates/login.html @@ -0,0 +1,29 @@ + + + Platypush login page + + + + + + + + +
+ +
+ + diff --git a/platypush/backend/http/templates/map.html b/platypush/backend/http/templates/map.html deleted file mode 100644 index c20199dd9..000000000 --- a/platypush/backend/http/templates/map.html +++ /dev/null @@ -1,46 +0,0 @@ - - - Platypush map service - - - - - - - - - - - - - - - - - - -
-
-
-
-
- - diff --git a/platypush/backend/http/templates/register.html b/platypush/backend/http/templates/register.html new file mode 100644 index 000000000..48dd4a5b3 --- /dev/null +++ b/platypush/backend/http/templates/register.html @@ -0,0 +1,32 @@ + + + Platypush registration page + + + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Keep me logged in on this device   +
+
+
+ + diff --git a/platypush/plugins/db/__init__.py b/platypush/plugins/db/__init__.py index 55128bbcc..62f6b5c01 100644 --- a/platypush/plugins/db/__init__.py +++ b/platypush/plugins/db/__init__.py @@ -43,7 +43,7 @@ class DbPlugin(Plugin): elif not isinstance(value, int) and not isinstance(value, float): value = "'{}'".format(str(value)) - return eval('{}.{}=={}'.format(table, column, value)) + return eval('table.c.{}=={}'.format(column, value)) @action def execute(self, statement, engine=None, *args, **kwargs): diff --git a/platypush/plugins/user.py b/platypush/plugins/user.py new file mode 100644 index 000000000..63fde28a4 --- /dev/null +++ b/platypush/plugins/user.py @@ -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: diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py new file mode 100644 index 000000000..3b2a651c4 --- /dev/null +++ b/platypush/user/__init__.py @@ -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: diff --git a/requirements.txt b/requirements.txt index 60f220ac7..ab7336f02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,6 +32,9 @@ requests # Database plugin support sqlalchemy +# Support for multi-users and password authentication +bcrypt + # RSS feeds support # feedparser