forked from platypush/platypush
Support for multi-users and authentication for HTTP pages
This commit is contained in:
parent
674c164fc1
commit
1c1ecc18df
21 changed files with 669 additions and 175 deletions
|
@ -209,6 +209,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
|
|||
'cv2',
|
||||
'nfc',
|
||||
'ndef',
|
||||
'bcrypt',
|
||||
]
|
||||
|
||||
sys.path.insert(0, os.path.abspath('../..'))
|
||||
|
|
|
@ -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', {})
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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/<hook_name>', 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,
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
46
platypush/backend/http/app/routes/login.py
Normal file
46
platypush/backend/http/app/routes/login.py
Normal 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:
|
|
@ -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/<device_id>/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/<device_id>/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')
|
||||
|
|
53
platypush/backend/http/app/routes/register.py
Normal file
53
platypush/backend/http/app/routes/register.py
Normal 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:
|
|
@ -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/<path:path>', methods=['GET'])
|
||||
def resources_path(path):
|
||||
""" Custom static resources """
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
color: $text-icon-color;
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
input[type=text],
|
||||
input[type=password] {
|
||||
border-radius: 5rem;
|
||||
|
||||
&:hover {
|
||||
|
|
46
platypush/backend/http/static/css/source/login/index.scss
Normal file
46
platypush/backend/http/static/css/source/login/index.scss
Normal 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;
|
||||
}
|
||||
|
1
platypush/backend/http/static/css/source/register
Symbolic link
1
platypush/backend/http/static/css/source/register
Symbolic link
|
@ -0,0 +1 @@
|
|||
login
|
|
@ -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();
|
||||
});
|
||||
|
29
platypush/backend/http/templates/login.html
Normal file
29
platypush/backend/http/templates/login.html
Normal 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 <input type="checkbox" name="remember">
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -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>
|
||||
|
32
platypush/backend/http/templates/register.html
Normal file
32
platypush/backend/http/templates/register.html
Normal 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 <input type="checkbox" name="remember">
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -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):
|
||||
|
|
138
platypush/plugins/user.py
Normal file
138
platypush/plugins/user.py
Normal 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
191
platypush/user/__init__.py
Normal 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:
|
|
@ -32,6 +32,9 @@ requests
|
|||
# Database plugin support
|
||||
sqlalchemy
|
||||
|
||||
# Support for multi-users and password authentication
|
||||
bcrypt
|
||||
|
||||
# RSS feeds support
|
||||
# feedparser
|
||||
|
||||
|
|
Loading…
Reference in a new issue