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',
'nfc',
'ndef',
'bcrypt',
]
sys.path.insert(0, os.path.abspath('../..'))

View file

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

View file

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

View file

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

View file

@ -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 = {}

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

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.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 """

View file

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

View file

@ -7,7 +7,8 @@
color: $text-icon-color;
}
input[type=text] {
input[type=text],
input[type=password] {
border-radius: 5rem;
&: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):
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
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
sqlalchemy
# Support for multi-users and password authentication
bcrypt
# RSS feeds support
# feedparser