Merge branch 'master' into snyk-upgrade-ba475f1c345b09c787ee96292badb5b6

This commit is contained in:
Fabio Manganiello 2024-11-05 12:06:33 +01:00 committed by GitHub
commit a4a56fdbee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
776 changed files with 24116 additions and 6254 deletions

View file

@ -6,8 +6,18 @@
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
# Clone the repository # Clone the repository
git remote add github git@github.com:/BlackLight/platypush.git branch=$(git rev-parse --abbrev-ref HEAD)
git pull --rebase github "$(git branch | head -1 | awk '{print $2}')" || echo "No such branch on Github" if [ -z "${branch}" ]; then
echo "No branch checked out"
exit 1
fi
git remote add github git@github.com:/blacklight/platypush.git
if (( "$branch" == "master" )); then
git pull --rebase github "${branch}" || echo "No such branch on Github"
fi
# Push the changes to the GitHub mirror # Push the changes to the GitHub mirror
git push --all -v github git push -f --all -v github
git push --tags -v github

View file

@ -16,7 +16,7 @@ apt install -y curl dpkg-dev gpg git python3 python3-pip python3-setuptools
echo "--- Parsing metadata" echo "--- Parsing metadata"
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
git pull --rebase origin master --tags git pull --rebase origin master --tags
export VERSION=$(python3 setup.py --version) export VERSION=$(grep -e '^__version__' "${SRCDIR}/version.py" | sed -r -e 's/^__version__\s*=\s*"([^"]+)"$/\1/')
export GIT_VERSION="$VERSION-$(git log --pretty=oneline HEAD...v$VERSION | wc -l)" export GIT_VERSION="$VERSION-$(git log --pretty=oneline HEAD...v$VERSION | wc -l)"
export GIT_BUILD_DIR="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all" export GIT_BUILD_DIR="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all"
export GIT_DEB="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all.deb" export GIT_DEB="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all.deb"

View file

@ -26,7 +26,7 @@ mkdir -p "$RPM_ROOT"
echo "--- Parsing metadata" echo "--- Parsing metadata"
git config --global --add safe.directory $PWD git config --global --add safe.directory $PWD
git pull --rebase origin master --tags git pull --rebase origin master --tags
export VERSION=$(python3 setup.py --version) export VERSION=$(grep -e '^__version__' "${SRCDIR}/version.py" | sed -r -e 's/^__version__\s*=\s*"([^"]+)"$/\1/')
export RELNUM="$(git log --pretty=oneline HEAD...v$VERSION | wc -l)" export RELNUM="$(git log --pretty=oneline HEAD...v$VERSION | wc -l)"
export SPECFILE="$WORKDIR/$PKG_NAME.spec" export SPECFILE="$WORKDIR/$PKG_NAME.spec"
export BUILD_DIR="$WORKDIR/build" export BUILD_DIR="$WORKDIR/build"

1
.ignore Normal file
View file

@ -0,0 +1 @@
dist/

View file

@ -1,5 +1,120 @@
# Changelog # Changelog
## [1.3.1]
- [[#344](https://git.platypush.tech/platypush/platypush/issues/344)]: removed
`marshmallow_dataclass` dependency. That package isn't included in the
package managers of any supported distros and requires to be installed via
pip. Making the Platypush' system packages depend on a pip-only package is
not a good idea. Plus, the library seems to be still in heavy development and
it has already broken compatibility with at least the `system` package.
## [1.3.0]
- [[#333](https://git.platypush.tech/platypush/platypush/issues/333)]: new file
browser UI/component. It includes custom MIME type support, a file editor
with syntax highlight, file download and file upload.
- [[#341](https://git.platypush.tech/platypush/platypush/issues/341)]:
procedures are now native entities that can be managed from the entities panel.
A new versatile procedure editor has also been added, with support for nested
blocks, conditions, loops, variables, context autocomplete, and more.
- [`procedure`]: Added the following features to YAML/structured procedures:
- `set`: to set variables whose scope is limited to the procedure / code
block where they are created. `variable.set` is useful to permanently
store variables on the db, `variable.mset` is useful to set temporary
global variables in memory through Redis, but sometimes you may just want
to assign a value to a variable that only needs to live within a procedure,
event hook or cron.
```yaml
- set:
foo: bar
temperature: ${output.get('temperature')}
```
- `return` can now return values too when invoked within a procedure:
```yaml
- return: something
# Or
- return: "Result: ${output.get('response')}"
```
- The default logging format is now much more compact. The full body of events
and requests is no longer included by default in `info` mode - instead, a
summary with the message type, ID and response time is logged. The full
payloads can still be logged by enabling `debug` logs through e.g. `-v`.
## [1.2.3]
- [[#422](https://git.platypush.tech/platypush/platypush/issues/422)]: adapted
media plugins to support streaming from the yt-dlp process. This allows
videos to have merged audio+video even if they had separate tracks upstream.
- [`media.*`] Many improvements on the media UI.
- [`zigbee.mqtt`] Removed synchronous logic from `zigbee.mqtt.device_set`. It
was prone to timeouts as well as pointless - the updated device state will
anyway be received as an event.
## [1.2.2]
- Fixed regression on older version of Python that don't fully support
`pyproject.toml` and can't install data files the new way.
## [1.2.1]
- Added static `/login` and `/register` Flask fallback routes to prevent 404 if
the client doesn't have JavaScript enabled.
- Fixed `apt` packages for Debian oldstable after the `setup.py` to
`pyproject.toml` migration.
- Fixed license string in the `pyproject.toml`.
## [1.2.0]
- [#419](https://git.platypush.tech/platypush/platypush/issues/419): added
support for randomly generated API tokens alongside JWT.
- [#339](https://git.platypush.tech/platypush/platypush/issues/339): added
support for 2FA with OTP codes.
- [#393](https://git.platypush.tech/platypush/platypush/issues/393): added
`bind_socket` parameter to `backend.http`, so Platypush can listen on (or
exclusively if `listen_port` is null) on a local UNIX socket as well.
- [#401](https://git.platypush.tech/platypush/platypush/issues/401): added
`--redis-bin` option / `PLATYPUSH_REDIS_BIN` environment variable to support
custom Redis (or drop-in replacements) executables when `--start-redis` is
specified.
- [#413](https://git.platypush.tech/platypush/platypush/issues/401): added
support for page-specific PWAs. If you navigate to `/plugin/<plugin-name>`,
and you install it as a PWA, you'll install a PWA only for that plugin - not
for the whole Platypush UI.
- Migrated project setup from `setup.py` to `pyproject.toml`.
- [`70db33b4e`](https://git.platypush.tech/platypush/platypush/commit/70db33b4e):
more application resilience in case Redis goes down.
- [`ee27b2c4`](https://git.platypush.tech/platypush/platypush/commit/ee27b2c4):
Refactor of all the authentication endpoints into a single `/auth` endpoint:
- `POST /register``POST /auth?type=register`
- `POST /login``POST /auth?type=login`
- `POST /auth``POST /auth?type=token`
- `POST /auth``POST /auth?type=jwt`
- [`2ccf0050`](https://git.platypush.tech/platypush/platypush/commit/2ccf0050):
Added support for binary content to `qrcode.generate`.
- [`b69e9500`](https://git.platypush.tech/platypush/platypush/commit/b69e9500):
Support for fullscreen mode on the `camera` plugins UI.
## [1.1.3] - 2024-07-16 ## [1.1.3] - 2024-07-16

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017, 2020 Fabio Manganiello Copyright (c) 2017, 2024 Fabio Manganiello
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -67,6 +67,7 @@
* [Other Web panels](#other-web-panels) * [Other Web panels](#other-web-panels)
* [Dashboards](#dashboards) * [Dashboards](#dashboards)
* [PWA support](#pwa-support) * [PWA support](#pwa-support)
- [Two-factor authentication](#two-factor-authentication)
- [Mobile app](#mobile-app) - [Mobile app](#mobile-app)
- [Browser extension](#browser-extension) - [Browser extension](#browser-extension)
- [Tests](#tests) - [Tests](#tests)
@ -1247,6 +1248,16 @@ PWA (progressive web app) to work. The Platypush PWA allows you to install a
Platypush native-like client on your mobile devices if you don't want to use the Platypush native-like client on your mobile devices if you don't want to use the
full Android app. full Android app.
## Two-factor authentication
Support for 2FA over OTP codes requires to enable the
[`otp`](https://docs.platypush.tech/platypush/plugins/otp.html) and
[`qrcode`](https://docs.platypush.tech/platypush/plugins/qrcode.html) plugins.
After installing the dependencies, you can enable it by navigating to
_Settings_ -> _Users_ from the Web panel. Then select your user, choose _Set up
2FA_ and proceed with the steps on screen to set up your authenticator.
## Mobile app ## Mobile app
An [official Android An [official Android

View file

@ -1,24 +1,9 @@
services: services:
platypush: platypush:
restart: "always" # Replace the build section with the next line if instead of building the
command: # image from a local checkout you want to pull the latest base
- platypush # (Alpine-based) image from the remote registry
# Comment --start-redis if you want to run an external Redis service # image: "registry.platypush.tech/platypush:latest"
# In such case you'll also have to ensure that the appropriate Redis
# variables are set in the .env file, or the Redis configuration is
# passed in the config.yaml, or use the --redis-host and --redis-port
# command-line options
- --start-redis
# Custom list of host devices that should be accessible to the container -
# e.g. an Arduino, an ESP-compatible microcontroller, a joystick etc.
# devices:
# - /dev/ttyUSB0
# Uncomment if you need plugins that require access to low-level hardware
# (e.g. Bluetooth BLE or GPIO/SPI/I2C) if access to individual devices is
# not enough or isn't practical
# privileged: true
build: build:
context: . context: .
@ -31,6 +16,25 @@ services:
# Fedora base image # Fedora base image
# dockerfile: ./platypush/install/docker/fedora.Dockerfile # dockerfile: ./platypush/install/docker/fedora.Dockerfile
restart: "always"
command:
- platypush
- --redis-host
- redis
# Or, if you want to run Redis from the same container as Platypush,
# replace --redis-host redis with the line below
# - --start-redis
# Custom list of host devices that should be accessible to the container -
# e.g. an Arduino, an ESP-compatible microcontroller, a joystick etc.
# devices:
# - /dev/ttyUSB0
# Uncomment if you need plugins that require access to low-level hardware
# (e.g. Bluetooth BLE or GPIO/SPI/I2C) if access to individual devices is
# not enough or isn't practical
# privileged: true
# Copy .env.example to .env and modify as needed # Copy .env.example to .env and modify as needed
# env_file: # env_file:
# - .env # - .env
@ -40,7 +44,13 @@ services:
# expose it # expose it
- "8008:8008" - "8008:8008"
volumes: # volumes:
- /path/to/your/config.yaml:/etc/platypush # Replace with a path that contains/will contain your config.yaml file
- /path/to/a/workdir:/var/lib/platypush # - /path/to/your/config:/etc/platypush
# Replace with a path that contains/will contain your working directory
# - /path/to/a/workdir:/var/lib/platypush
# Optionally, use an external volume for the cache
# - /path/to/a/cachedir:/var/cache/platypush # - /path/to/a/cachedir:/var/cache/platypush
redis:
image: redis

View file

@ -1,6 +0,0 @@
``media.omxplayer``
=====================================
.. automodule:: platypush.plugins.media.omxplayer
:members:

View file

@ -0,0 +1,5 @@
``procedures``
==============
.. automodule:: platypush.plugins.procedures
:members:

View file

@ -77,7 +77,6 @@ Plugins
platypush/plugins/media.kodi.rst platypush/plugins/media.kodi.rst
platypush/plugins/media.mplayer.rst platypush/plugins/media.mplayer.rst
platypush/plugins/media.mpv.rst platypush/plugins/media.mpv.rst
platypush/plugins/media.omxplayer.rst
platypush/plugins/media.plex.rst platypush/plugins/media.plex.rst
platypush/plugins/media.subtitles.rst platypush/plugins/media.subtitles.rst
platypush/plugins/media.vlc.rst platypush/plugins/media.vlc.rst
@ -100,6 +99,7 @@ Plugins
platypush/plugins/otp.rst platypush/plugins/otp.rst
platypush/plugins/pihole.rst platypush/plugins/pihole.rst
platypush/plugins/ping.rst platypush/plugins/ping.rst
platypush/plugins/procedures.rst
platypush/plugins/pushbullet.rst platypush/plugins/pushbullet.rst
platypush/plugins/pwm.pca9685.rst platypush/plugins/pwm.pca9685.rst
platypush/plugins/qrcode.rst platypush/plugins/qrcode.rst

View file

@ -21,9 +21,8 @@ from .utils import run
# see https://git.platypush.tech/platypush/platypush/issues/399 # see https://git.platypush.tech/platypush/platypush/issues/399
when = hook when = hook
__version__ = '1.3.1'
__author__ = 'Fabio Manganiello <fabio@manganiello.tech>' __author__ = 'Fabio Manganiello <fabio@manganiello.tech>'
__version__ = '1.1.3'
__all__ = [ __all__ = [
'Application', 'Application',
'Variable', 'Variable',
@ -41,6 +40,7 @@ __all__ = [
'procedure', 'procedure',
'run', 'run',
'when', 'when',
'__version__',
] ]

View file

@ -365,7 +365,13 @@ class Application:
elif isinstance(msg, Response): elif isinstance(msg, Response):
msg.log() msg.log()
elif isinstance(msg, Event): elif isinstance(msg, Event):
msg.log() log.info(
'Received event: %s.%s[id=%s]',
msg.__class__.__module__,
msg.__class__.__name__,
msg.id,
)
msg.log(level=logging.DEBUG)
self.event_processor.process_event(msg) self.event_processor.process_event(msg)
return _f return _f

View file

@ -10,8 +10,6 @@ from multiprocessing import Process
from time import time from time import time
from typing import Mapping, Optional from typing import Mapping, Optional
import psutil
from tornado.httpserver import HTTPServer from tornado.httpserver import HTTPServer
from tornado.netutil import bind_sockets, bind_unix_socket from tornado.netutil import bind_sockets, bind_unix_socket
from tornado.process import cpu_count, fork_processes from tornado.process import cpu_count, fork_processes
@ -421,6 +419,14 @@ class HttpBackend(Backend):
workers when the server terminates: workers when the server terminates:
https://github.com/tornadoweb/tornado/issues/1912. https://github.com/tornadoweb/tornado/issues/1912.
""" """
try:
import psutil
except ImportError:
self.logger.warning(
'Could not import psutil, hanging worker processes might remain active'
)
return
parent_pid = ( parent_pid = (
self._server_proc.pid self._server_proc.pid
if self._server_proc and self._server_proc.pid if self._server_proc and self._server_proc.pid

View file

@ -4,8 +4,15 @@ import logging
from flask import Blueprint, request, abort, jsonify from flask import Blueprint, request, abort, jsonify
from platypush.backend.http.app.utils import authenticate
from platypush.backend.http.app.utils.auth import (
UserAuthStatus,
current_user,
get_current_user_or_auth_status,
)
from platypush.exceptions.user import UserException from platypush.exceptions.user import UserException
from platypush.user import UserManager from platypush.user import User, UserManager
from platypush.utils import utcnow
auth = Blueprint('auth', __name__) auth = Blueprint('auth', __name__)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -16,39 +23,24 @@ __routes__ = [
] ]
@auth.route('/auth', methods=['POST']) def _dump_session(session, redirect_page='/'):
def auth_endpoint(): return jsonify(
""" {
Authentication endpoint. It validates the user credentials provided over a JSON payload with the following 'status': 'ok',
structure: 'user_id': session.user_id,
'session_token': session.session_token,
'expires_at': session.expires_at,
'redirect': redirect_page,
}
)
.. code-block:: json
{ def _jwt_auth():
"username": "USERNAME",
"password": "PASSWORD",
"expiry_days": "The generated token should be valid for these many days"
}
``expiry_days`` is optional, and if omitted or set to zero the token will be valid indefinitely.
Upon successful validation, a new JWT token will be generated using the service's self-generated RSA key-pair and it
will be returned to the user. The token can then be used to authenticate API calls to ``/execute`` by setting the
``Authorization: Bearer <TOKEN_HERE>`` header upon HTTP calls.
:return: Return structure:
.. code-block:: json
{
"token": "<generated token here>"
}
"""
try: try:
payload = json.loads(request.get_data(as_text=True)) payload = json.loads(request.get_data(as_text=True))
username, password = payload['username'], payload['password'] username, password = payload['username'], payload['password']
except Exception as e: except Exception:
log.warning('Invalid payload passed to the auth endpoint: ' + str(e)) log.warning('Invalid payload passed to the auth endpoint')
abort(400) abort(400)
expiry_days = payload.get('expiry_days') expiry_days = payload.get('expiry_days')
@ -59,8 +51,366 @@ def auth_endpoint():
user_manager = UserManager() user_manager = UserManager()
try: try:
return jsonify({ return jsonify(
'token': user_manager.generate_jwt_token(username=username, password=password, expires_at=expires_at), {
}) 'token': user_manager.generate_jwt_token(
username=username, password=password, expires_at=expires_at
),
}
)
except UserException as e: except UserException as e:
abort(401, str(e)) abort(401, str(e))
def _session_auth():
user_manager = UserManager()
session_token = request.cookies.get('session_token')
redirect_page = request.args.get('redirect') or '/'
if session_token:
user, session = user_manager.authenticate_user_session(session_token)[:2]
if user and session:
return _dump_session(session, redirect_page)
if request.form:
username = request.form.get('username')
password = request.form.get('password')
code = request.form.get('code')
remember = request.form.get('remember')
expires = utcnow() + datetime.timedelta(days=365) if remember else None
session, status = user_manager.create_user_session( # type: ignore
username=username,
password=password,
code=code,
expires_at=expires,
with_status=True,
)
if session:
return _dump_session(session, redirect_page)
if status:
auth_status = UserAuthStatus.by_status(status)
assert auth_status
return auth_status.to_response()
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
def _create_token():
payload = {}
try:
payload = json.loads(request.get_data(as_text=True))
except json.JSONDecodeError:
pass
user = None
username = payload.get('username')
password = payload.get('password')
code = payload.get('code')
name = payload.get('name')
expiry_days = payload.get('expiry_days')
user_manager = UserManager()
response = get_current_user_or_auth_status(request)
# Try and authenticate with the credentials passed in the JSON payload
if username and password:
user = user_manager.authenticate_user(username, password, code=code)
if not isinstance(user, User):
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
if not user:
if not (response and isinstance(response, User)):
return response.to_response()
user = response
expires_at = None
if expiry_days:
expires_at = datetime.datetime.now() + datetime.timedelta(days=expiry_days)
try:
token = UserManager().generate_api_token(
username=str(user.username), name=name, expires_at=expires_at
)
return jsonify({'token': token})
except UserException:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
def _delete_token():
try:
payload = json.loads(request.get_data(as_text=True))
token = payload.get('token')
assert token
except (AssertionError, json.JSONDecodeError):
return UserAuthStatus.INVALID_TOKEN.to_response()
user_manager = UserManager()
try:
token = payload.get('token')
if not token:
return UserAuthStatus.INVALID_TOKEN.to_response()
ret = user_manager.delete_api_token(token)
if not ret:
return UserAuthStatus.INVALID_TOKEN.to_response()
return jsonify({'status': 'ok'})
except UserException:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
def _register_route():
"""Registration endpoint"""
user_manager = UserManager()
session_token = request.cookies.get('session_token')
redirect_page = request.args.get('redirect') or '/'
if session_token:
user, session = user_manager.authenticate_user_session(session_token)[:2]
if user and session:
return _dump_session(session, redirect_page)
if user_manager.get_user_count() > 0:
return UserAuthStatus.REGISTRATION_DISABLED.to_response()
if not request.form:
return UserAuthStatus.MISSING_USERNAME.to_response()
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
remember = request.form.get('remember')
if not username:
return UserAuthStatus.MISSING_USERNAME.to_response()
if not password:
return UserAuthStatus.MISSING_PASSWORD.to_response()
if password != confirm_password:
return UserAuthStatus.PASSWORD_MISMATCH.to_response()
user_manager.create_user(username=username, password=password)
session, status = user_manager.create_user_session( # type: ignore
username=username,
password=password,
expires_at=(utcnow() + datetime.timedelta(days=365) if remember else None),
with_status=True,
)
if session:
return _dump_session(session, redirect_page)
if status:
return status.to_response() # type: ignore
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
def _auth_get():
"""
Get the current authentication status of the user session.
"""
user_manager = UserManager()
session_token = request.cookies.get('session_token')
redirect_page = request.args.get('redirect') or '/'
user, session, status = user_manager.authenticate_user_session( # type: ignore
session_token, with_status=True
)
if user and session:
return _dump_session(session, redirect_page)
response = get_current_user_or_auth_status(request)
if isinstance(response, User):
user = response
return jsonify(
{'status': 'ok', 'user_id': user.user_id, 'username': user.username}
)
if response:
status = response
if status:
if not isinstance(status, UserAuthStatus):
status = UserAuthStatus.by_status(status)
if not status:
status = UserAuthStatus.INVALID_CREDENTIALS
return status.to_response()
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
def _auth_post():
"""
Authenticate the user session.
"""
auth_type = request.args.get('type') or 'token'
if auth_type == 'token':
return _create_token()
if auth_type == 'jwt':
return _jwt_auth()
if auth_type == 'register':
return _register_route()
if auth_type == 'login':
return _session_auth()
return UserAuthStatus.INVALID_AUTH_TYPE.to_response()
def _auth_delete():
"""
Logout/invalidate a token or the current user session.
"""
# Delete the specified API token if it's passed on the JSON payload
token = None
try:
payload = json.loads(request.get_data(as_text=True))
token = payload.get('token')
except json.JSONDecodeError:
pass
if token:
return _delete_token()
user_manager = UserManager()
session_token = request.cookies.get('session_token')
redirect_page = request.args.get('redirect') or '/'
if session_token:
user, session = user_manager.authenticate_user_session(session_token)[:2]
if user and session:
user_manager.delete_user_session(session_token)
return jsonify({'status': 'ok', 'redirect': redirect_page})
return UserAuthStatus.INVALID_SESSION.to_response()
def _tokens_get():
user = current_user()
if not user:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
tokens = UserManager().get_api_tokens(username=str(user.username))
return jsonify(
{
'tokens': [
{
'id': t.id,
'name': t.name,
'created_at': t.created_at,
'expires_at': t.expires_at,
}
for t in tokens
]
}
)
def _tokens_delete():
args = {}
try:
payload = json.loads(request.get_data(as_text=True))
token = payload.get('token')
if token:
args['token'] = token
else:
token_id = payload.get('token_id')
if token_id:
args['token_id'] = token_id
assert args, 'No token or token_id specified'
except (AssertionError, json.JSONDecodeError):
return UserAuthStatus.INVALID_TOKEN.to_response()
user_manager = UserManager()
user = current_user()
if not user:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
args['username'] = str(user.username)
try:
user_manager.delete_api_token(**args)
return jsonify({'status': 'ok'})
except AssertionError as e:
return (
jsonify({'status': 'error', 'error': 'bad_request', 'message': str(e)}),
400,
)
except UserException:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
except Exception as e:
log.error('Token deletion error', exc_info=e)
return UserAuthStatus.UNKNOWN_ERROR.to_response()
@auth.route('/auth', methods=['GET', 'POST', 'DELETE'])
def auth_endpoint():
"""
Authentication endpoint. It validates the user credentials provided over a
JSON payload with the following structure:
.. code-block:: json
{
"username": "USERNAME",
"password": "PASSWORD",
"code": "2FA_CODE",
"expiry_days": "The generated token should be valid for these many days"
}
``expiry_days`` is optional, and if omitted or set to zero the token will
be valid indefinitely.
Upon successful validation, a new JWT token will be generated using the
service's self-generated RSA key-pair and it will be returned to the user.
The token can then be used to authenticate API calls to ``/execute`` by
setting the ``Authorization: Bearer <TOKEN_HERE>`` header upon HTTP calls.
:return: Return structure:
.. code-block:: json
{
"token": "<generated token here>"
}
"""
if request.method == 'GET':
return _auth_get()
if request.method == 'POST':
return _auth_post()
if request.method == 'DELETE':
return _auth_delete()
return UserAuthStatus.INVALID_METHOD.to_response()
@auth.route('/tokens', methods=['GET', 'DELETE'])
@authenticate()
def tokens_route():
"""
:return: The list of API tokens created by the logged in user.
Note that this endpoint is only accessible by authenticated users
and it won't return the clear-text token values, as those aren't
stored in the database anyway.
"""
if request.method == 'GET':
return _tokens_get()
if request.method == 'DELETE':
return _tokens_delete()
return UserAuthStatus.INVALID_METHOD.to_response()
# vim:sw=4:ts=4:et:

View file

@ -14,8 +14,26 @@ __routes__ = [
@index.route('/') @index.route('/')
@authenticate() @authenticate()
def index(): def index_route():
""" Route to the main web panel """ """Route to the main web panel"""
return render_template('index.html', utils=HttpUtils)
@index.route('/login', methods=['GET'])
def login_route():
"""
Login GET route. It simply renders the index template, which will
redirect to the login page if the user is not authenticated.
"""
return render_template('index.html', utils=HttpUtils)
@index.route('/register', methods=['GET'])
def register_route():
"""
Register GET route. It simply renders the index template, which will
redirect to the registration page if no users are present.
"""
return render_template('index.html', utils=HttpUtils) return render_template('index.html', utils=HttpUtils)

View file

@ -1,56 +0,0 @@
import datetime
import re
from flask import Blueprint, request, redirect, render_template, make_response
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
from platypush.utils import utcnow
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()
session_token = request.cookies.get('session_token')
redirect_page = request.args.get('redirect')
if not redirect_page:
redirect_page = request.headers.get('Referer', '/')
if re.search('(^https?://[^/]+)?/login[^?#]?', redirect_page):
# Prevent redirect loop
redirect_page = '/'
if session_token:
user, session = user_manager.authenticate_user_session(session_token)
if user:
return redirect(redirect_page, 302) # lgtm [py/url-redirection]
if request.form:
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember')
expires = utcnow() + datetime.timedelta(days=365) if remember else None
session = user_manager.create_user_session(
username=username, password=password, expires_at=expires
)
if session:
redirect_target = redirect(redirect_page, 302) # lgtm [py/url-redirection]
response = make_response(redirect_target)
response.set_cookie('session_token', session.session_token, expires=expires)
return response
return render_template('index.html', utils=HttpUtils)
# vim:sw=4:ts=4:et:

View file

@ -12,7 +12,7 @@ __routes__ = [
@logout.route('/logout', methods=['GET', 'POST']) @logout.route('/logout', methods=['GET', 'POST'])
def logout(): def logout_route():
"""Logout page""" """Logout page"""
user_manager = UserManager() user_manager = UserManager()
redirect_page = request.args.get( redirect_page = request.args.get(
@ -23,7 +23,7 @@ def logout():
if not session_token: if not session_token:
abort(417, 'Not logged in') abort(417, 'Not logged in')
user, _ = user_manager.authenticate_user_session(session_token) user, _ = user_manager.authenticate_user_session(session_token)[:2]
if not user: if not user:
abort(403, 'Invalid session token') abort(403, 'Invalid session token')

View file

@ -0,0 +1,220 @@
from typing import List, Optional
from flask import Blueprint, jsonify, request
from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import UserAuthStatus, authenticate
from platypush.backend.http.utils import HttpUtils
from platypush.exceptions.user import (
InvalidCredentialsException,
InvalidOtpCodeException,
UserException,
)
from platypush.config import Config
from platypush.context import get_plugin
from platypush.user import UserManager
otp = Blueprint('otp', __name__, template_folder=template_folder)
# Declare routes list
__routes__ = [
otp,
]
def _get_otp_and_qrcode():
otp = get_plugin('otp') # pylint: disable=redefined-outer-name
qrcode = get_plugin('qrcode')
assert (
otp and qrcode
), 'The otp and/or qrcode plugins are not available in your installation'
return otp, qrcode
def _get_username():
user = HttpUtils.current_user()
if not user:
raise InvalidCredentialsException('Invalid user session')
return str(user.username)
def _get_otp_uri_and_qrcode(username: str, otp_secret: Optional[str] = None):
if not otp_secret:
return None, None
otp, qrcode = _get_otp_and_qrcode() # pylint: disable=redefined-outer-name
otp_uri = (
otp.provision_time_otp(
name=username,
secret=otp_secret,
issuer=f'platypush@{Config.get_device_id()}',
).output
if otp_secret
else None
)
otp_qrcode = (
qrcode.generate(content=otp_uri, format='png').output.get('data')
if otp_uri
else None
)
return otp_uri, otp_qrcode
def _verify_code(code: str, otp_secret: str) -> bool:
otp, _ = _get_otp_and_qrcode() # pylint: disable=redefined-outer-name
return otp.verify_time_otp(otp=code, secret=otp_secret).output
def _dump_response(
username: str,
otp_secret: Optional[str] = None,
backup_codes: Optional[List[str]] = None,
):
otp_uri, otp_qrcode = _get_otp_uri_and_qrcode(username, otp_secret)
return jsonify(
{
'username': username,
'otp_secret': otp_secret,
'otp_uri': otp_uri,
'qrcode': otp_qrcode,
'backup_codes': backup_codes or [],
}
)
def _get_otp():
username = _get_username()
user_manager = UserManager()
otp_secret = user_manager.get_otp_secret(username)
return _dump_response(
username=username,
otp_secret=otp_secret,
)
def _authenticate_user(username: str, password: Optional[str]):
assert password, 'The password field is required when setting up OTP'
user, auth_status = UserManager().authenticate_user( # type: ignore
username, password, skip_2fa=True, with_status=True
)
if not user:
raise InvalidCredentialsException(auth_status.value[2])
def _post_otp():
body = request.json
assert body, 'Invalid request body'
username = _get_username()
dry_run = body.get('dry_run', False)
otp_secret = body.get('otp_secret')
if not dry_run:
_authenticate_user(username, body.get('password'))
if otp_secret:
code = body.get('code')
assert code, 'The code field is required when setting up OTP'
if not _verify_code(code, otp_secret):
raise InvalidOtpCodeException()
user_manager = UserManager()
user_otp, backup_codes = user_manager.enable_otp(
username=username,
otp_secret=otp_secret,
dry_run=dry_run,
)
return _dump_response(
username=username,
otp_secret=str(user_otp.otp_secret),
backup_codes=backup_codes,
)
def _delete_otp():
body = request.json
assert body, 'Invalid request body'
username = _get_username()
_authenticate_user(username, body.get('password'))
user_manager = UserManager()
user_manager.disable_otp(username)
return jsonify({'status': 'ok'})
@otp.route('/otp/config', methods=['GET', 'POST', 'DELETE'])
@authenticate()
def otp_route():
"""
:return: The user's current MFA/OTP configuration:
.. code-block:: json
{
"username": "testuser",
"otp_secret": "JBSA6ZUZ5DPEK7YV",
"otp_uri": "otpauth://totp/testuser?secret=JBSA6ZUZ5DPEK7YV&issuer=platypush@localhost",
"qrcode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAABwklEQVR4nO3dMW7CQBAF0",
"backup_codes": [
"1A2B3C4D5E",
"6F7G8H9I0J",
"KLMNOPQRST",
"UVWXYZ1234",
"567890ABCD",
"EFGHIJKLMN",
"OPQRSTUVWX",
"YZ12345678",
"90ABCDEF12",
"34567890AB"
]
}
"""
try:
if request.method.lower() == 'get':
return _get_otp()
if request.method.lower() == 'post':
return _post_otp()
if request.method.lower() == 'delete':
return _delete_otp()
return jsonify({'error': 'Method not allowed'}), 405
except AssertionError as e:
return jsonify({'error': str(e)}), 400
except InvalidCredentialsException:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
except InvalidOtpCodeException:
return UserAuthStatus.INVALID_OTP_CODE.to_response()
except UserException as e:
return jsonify({'error': e.__class__.__name__, 'message': str(e)}), 401
except Exception as e:
HttpUtils.log.error(f'Error while processing OTP request: {e}', exc_info=True)
return jsonify({'error': str(e)}), 500
@otp.route('/otp/refresh-codes', methods=['POST'])
def refresh_codes():
"""
:return: A new set of backup codes for the user.
"""
username = _get_username()
user_manager = UserManager()
otp_secret = user_manager.get_otp_secret(username)
if not otp_secret:
return jsonify({'error': 'OTP not configured for the user'}), 400
backup_codes = user_manager.refresh_user_backup_codes(username)
return jsonify({'backup_codes': backup_codes})
# vim:sw=4:ts=4:et:

View file

@ -1,71 +0,0 @@
import datetime
import re
from flask import Blueprint, request, redirect, render_template, make_response, abort
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
from platypush.utils import utcnow
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')
if not redirect_page:
redirect_page = request.headers.get('Referer', '/')
if re.search('(^https?://[^/]+)?/register[^?#]?', redirect_page):
# Prevent redirect loop
redirect_page = '/'
session_token = request.cookies.get('session_token')
if session_token:
user, session = user_manager.authenticate_user_session(session_token)
if user:
return redirect(redirect_page, 302) # lgtm [py/url-redirection]
if user_manager.get_user_count() > 0:
return redirect(
'/login?redirect=' + redirect_page, 302
) # lgtm [py/url-redirection]
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=(
utcnow() + datetime.timedelta(days=1) if not remember else None
),
)
if session:
redirect_target = redirect(
redirect_page, 302
) # lgtm [py/url-redirection]
response = make_response(redirect_target)
response.set_cookie('session_token', session.session_token)
return response
else:
abort(400, 'Password mismatch')
return render_template('index.html', utils=HttpUtils)
# vim:sw=4:ts=4:et:

View file

@ -7,7 +7,7 @@ from typing import Optional
from tornado.web import RequestHandler, stream_request_body from tornado.web import RequestHandler, stream_request_body
from platypush.backend.http.app.utils import logger from platypush.backend.http.app.utils import logger
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status from platypush.backend.http.app.utils.auth import UserAuthStatus, get_auth_status
from ..mixins import PubSubMixin from ..mixins import PubSubMixin
@ -29,7 +29,7 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
""" """
if self.auth_required: if self.auth_required:
auth_status = get_auth_status(self.request) auth_status = get_auth_status(self.request)
if auth_status != AuthStatus.OK: if auth_status != UserAuthStatus.OK:
self.send_error(auth_status.value.code, error=auth_status.value.message) self.send_error(auth_status.value.code, error=auth_status.value.message)
return return

View file

@ -1,7 +1,8 @@
import os import os
import pathlib
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime as dt from datetime import datetime as dt
from typing import Optional, Tuple from typing import IO, Optional, Tuple
from tornado.web import stream_request_body from tornado.web import stream_request_body
@ -17,6 +18,8 @@ class FileRoute(StreamingRoute):
""" """
BUFSIZE = 1024 BUFSIZE = 1024
_bytes_written = 0
_out_f: Optional[IO[bytes]] = None
@classmethod @classmethod
def path(cls) -> str: def path(cls) -> str:
@ -39,6 +42,10 @@ class FileRoute(StreamingRoute):
def file_size(self) -> int: def file_size(self) -> int:
return os.path.getsize(self.file_path) return os.path.getsize(self.file_path)
@property
def _content_length(self) -> int:
return int(self.request.headers.get('Content-Length', 0))
@property @property
def range(self) -> Tuple[Optional[int], Optional[int]]: def range(self) -> Tuple[Optional[int], Optional[int]]:
range_hdr = self.request.headers.get('Range') range_hdr = self.request.headers.get('Range')
@ -105,6 +112,77 @@ class FileRoute(StreamingRoute):
self.finish() self.finish()
def on_finish(self) -> None:
if self._out_f:
try:
if not (self._out_f and self._out_f.closed):
self._out_f.close()
except Exception as e:
self.logger.warning('Error while closing the output file: %s', e)
self._out_f = None
return super().on_finish()
def _validate_upload(self, force: bool = False) -> bool:
if not self.file_path:
self.write_error(400, 'Missing path argument')
return False
if not self._out_f:
if not force and os.path.exists(self.file_path):
self.write_error(409, f'{self.file_path} already exists')
return False
self._bytes_written = 0
dir_path = os.path.dirname(self.file_path)
try:
pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True)
self._out_f = open( # pylint: disable=consider-using-with
self.file_path, 'wb'
)
except PermissionError:
self.write_error(403, 'Permission denied')
return False
return True
def finish(self, *args, **kwargs): # type: ignore
try:
return super().finish(*args, **kwargs)
except Exception as e:
self.logger.warning('Error while finishing the request: %s', e)
def data_received(self, chunk: bytes):
# Ignore unless we're in POST/PUT mode
if self.request.method not in ('POST', 'PUT'):
return
force = self.request.method == 'PUT'
if not self._validate_upload(force=force):
self.finish()
return
if not chunk:
self.logger.debug('Received EOF from client')
self.finish()
return
assert self._out_f
self._out_f.write(chunk)
self._out_f.flush()
self._bytes_written += len(chunk)
self.logger.debug(
'Written chunk of size %d to %s, progress: %d/%d',
len(chunk),
self.file_path,
self._bytes_written,
self._content_length,
)
self.flush()
def get(self) -> None: def get(self) -> None:
with self._serve() as f: with self._serve() as f:
if f: if f:
@ -119,3 +197,9 @@ class FileRoute(StreamingRoute):
def head(self) -> None: def head(self) -> None:
with self._serve(): with self._serve():
pass pass
def post(self) -> None:
self.logger.info('Receiving file POST upload request for %r', self.file_path)
def put(self) -> None:
self.logger.info('Receiving file PUT upload request for %r', self.file_path)

View file

@ -3,7 +3,9 @@ from typing import Optional
from platypush.backend.http.app.utils import logger, send_request from platypush.backend.http.app.utils import logger, send_request
from platypush.backend.http.media.handlers import MediaHandler from platypush.backend.http.media.handlers import MediaHandler
from ._registry import load_media_map, save_media_map from ._registry import clear_media_map, load_media_map, save_media_map
_init = False
def get_media_url(media_id: str) -> str: def get_media_url(media_id: str) -> str:
@ -17,6 +19,12 @@ def register_media(source: str, subtitles: Optional[str] = None) -> MediaHandler
""" """
Registers a media file and returns its associated media handler. Registers a media file and returns its associated media handler.
""" """
global _init
if not _init:
clear_media_map()
_init = True
media_id = MediaHandler.get_media_id(source) media_id = MediaHandler.get_media_id(source)
media_url = get_media_url(media_id) media_url = get_media_url(media_id)
media_map = load_media_map() media_map = load_media_map()

View file

@ -25,10 +25,15 @@ def load_media_map() -> MediaMap:
logger().warning('Could not load media map: %s', e) logger().warning('Could not load media map: %s', e)
return {} return {}
return { parsed_map = {}
media_id: MediaHandler.build(**media_info) for media_id, media_info in media_map.items():
for media_id, media_info in media_map.items() try:
} parsed_map[media_id] = MediaHandler.build(**media_info)
except Exception as e:
logger().debug('Could not load media %s: %s', media_id, e)
continue
return parsed_map
def save_media_map(new_map: MediaMap): def save_media_map(new_map: MediaMap):
@ -38,3 +43,12 @@ def save_media_map(new_map: MediaMap):
with media_map_lock: with media_map_lock:
redis = get_redis() redis = get_redis()
redis.mset({MEDIA_MAP_VAR: json.dumps(new_map, cls=Message.Encoder)}) redis.mset({MEDIA_MAP_VAR: json.dumps(new_map, cls=Message.Encoder)})
def clear_media_map():
"""
Clears the media map from the server.
"""
with media_map_lock:
redis = get_redis()
redis.delete(MEDIA_MAP_VAR)

View file

@ -17,7 +17,7 @@ class MediaStreamRoute(StreamingRoute):
Route for media streams. Route for media streams.
""" """
SUPPORTED_METHODS = ['GET', 'PUT', 'DELETE'] SUPPORTED_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -50,6 +50,23 @@ class MediaStreamRoute(StreamingRoute):
except Exception as e: except Exception as e:
self._on_error(e) self._on_error(e)
def head(self, media_id: Optional[str] = None):
"""
Streams a media resource by ID.
"""
if not media_id:
self.finish()
return
# Strip the extension
media_id = '.'.join(media_id.split('.')[:-1])
try:
self.stream_media(media_id, head=True)
except Exception as e:
self._on_error(e)
def put(self, *_, **__): def put(self, *_, **__):
""" """
The `PUT` route is used to prepare a new media resource for streaming. The `PUT` route is used to prepare a new media resource for streaming.
@ -93,10 +110,10 @@ class MediaStreamRoute(StreamingRoute):
""" """
Returns the list of registered media resources. Returns the list of registered media resources.
""" """
self.add_header('Content-Type', 'application/json') self.set_header('Content-Type', 'application/json')
self.finish(json.dumps([dict(media) for media in load_media_map().values()])) self.finish(json.dumps([dict(media) for media in load_media_map().values()]))
def stream_media(self, media_id: str): def stream_media(self, media_id: str, head: bool = False):
""" """
Route to stream a media file given its ID. Route to stream a media file given its ID.
""" """
@ -107,11 +124,11 @@ class MediaStreamRoute(StreamingRoute):
range_hdr = self.request.headers.get('Range') range_hdr = self.request.headers.get('Range')
content_length = media_hndl.content_length content_length = media_hndl.content_length
self.add_header('Accept-Ranges', 'bytes') self.set_header('Accept-Ranges', 'bytes')
self.add_header('Content-Type', media_hndl.mime_type) self.set_header('Content-Type', media_hndl.mime_type)
if 'download' in self.request.arguments: if 'download' in self.request.arguments:
self.add_header( self.set_header(
'Content-Disposition', 'Content-Disposition',
'attachment' 'attachment'
+ ('; filename="{media_hndl.filename}"' if media_hndl.filename else ''), + ('; filename="{media_hndl.filename}"' if media_hndl.filename else ''),
@ -129,7 +146,7 @@ class MediaStreamRoute(StreamingRoute):
content_length = to_bytes - from_bytes content_length = to_bytes - from_bytes
self.set_status(206) self.set_status(206)
self.add_header( self.set_header(
'Content-Range', 'Content-Range',
f'bytes {from_bytes}-{to_bytes}/{media_hndl.content_length}', f'bytes {from_bytes}-{to_bytes}/{media_hndl.content_length}',
) )
@ -137,7 +154,13 @@ class MediaStreamRoute(StreamingRoute):
from_bytes = 0 from_bytes = 0
to_bytes = STREAMING_BLOCK_SIZE to_bytes = STREAMING_BLOCK_SIZE
self.add_header('Content-Length', str(content_length)) self.set_header('Content-Length', str(content_length))
if head:
self.flush()
self.finish()
return
for chunk in media_hndl.get_data( for chunk in media_hndl.get_data(
from_bytes=from_bytes, from_bytes=from_bytes,
to_bytes=to_bytes, to_bytes=to_bytes,

View file

@ -1,7 +1,9 @@
from .auth import ( from .auth import (
UserAuthStatus,
authenticate, authenticate,
authenticate_token, authenticate_token,
authenticate_user_pass, authenticate_user_pass,
current_user,
get_auth_status, get_auth_status,
) )
from .bus import bus, send_message, send_request from .bus import bus, send_message, send_request
@ -17,10 +19,12 @@ from .streaming import get_streaming_routes
from .ws import get_ws_routes from .ws import get_ws_routes
__all__ = [ __all__ = [
'UserAuthStatus',
'authenticate', 'authenticate',
'authenticate_token', 'authenticate_token',
'authenticate_user_pass', 'authenticate_user_pass',
'bus', 'bus',
'current_user',
'get_auth_status', 'get_auth_status',
'get_http_port', 'get_http_port',
'get_ip_or_hostname', 'get_ip_or_hostname',

View file

@ -1,15 +1,15 @@
import base64 import base64
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional, Union
from flask import request, redirect, jsonify from flask import request, redirect
from flask.wrappers import Response from flask.wrappers import Response
from platypush.config import Config from platypush.config import Config
from platypush.user import UserManager from platypush.user import User, UserManager
from ..logger import logger from ..logger import logger
from .status import AuthStatus from .status import UserAuthStatus
user_manager = UserManager() user_manager = UserManager()
@ -41,8 +41,8 @@ def get_cookie(req, name: str) -> Optional[str]:
return cookie.value return cookie.value
def authenticate_token(req): def authenticate_token(req) -> Optional[User]:
token = Config.get('token') global_token = Config.get('user.global_token')
user_token = None user_token = None
if 'X-Token' in req.headers: if 'X-Token' in req.headers:
@ -55,14 +55,27 @@ def authenticate_token(req):
user_token = get_arg(req, 'token') user_token = get_arg(req, 'token')
if not user_token: if not user_token:
return False return None
try: try:
user_manager.validate_jwt_token(user_token) # Stantard API token authentication
return True return user_manager.validate_api_token(user_token)
except Exception as e: except Exception as e:
logger().debug(str(e)) try:
return bool(token and user_token == token) # Legacy JWT token authentication
return user_manager.validate_jwt_token(user_token)
except Exception as ee:
logger().debug(
'Invalid token. API token error: %s, JWT token error: %s', e, ee
)
# Legacy global token authentication.
# The global token should be specified in the configuration file,
# as a root parameter named `token`.
if bool(global_token and user_token == global_token):
return User(username='__token__', user_id=1)
logger().info(e)
def authenticate_user_pass(req): def authenticate_user_pass(req):
@ -91,7 +104,7 @@ def authenticate_user_pass(req):
return user_manager.authenticate_user(username, password) return user_manager.authenticate_user(username, password)
def authenticate_session(req): def authenticate_session(req) -> Optional[User]:
user = None user = None
# Check the X-Session-Token header # Check the X-Session-Token header
@ -106,9 +119,9 @@ def authenticate_session(req):
user_session_token = get_cookie(req, 'session_token') user_session_token = get_cookie(req, 'session_token')
if user_session_token: if user_session_token:
user, _ = user_manager.authenticate_user_session(user_session_token) user, _ = user_manager.authenticate_user_session(user_session_token)[:2]
return user is not None return user
def authenticate( def authenticate(
@ -128,18 +141,18 @@ def authenticate(
skip_auth_methods=skip_auth_methods, skip_auth_methods=skip_auth_methods,
) )
if auth_status == AuthStatus.OK: if auth_status == UserAuthStatus.OK:
return f(*args, **kwargs) return f(*args, **kwargs)
if json: if json:
return jsonify(auth_status.to_dict()), auth_status.value.code return auth_status.to_response()
if auth_status == AuthStatus.NO_USERS: if auth_status == UserAuthStatus.REGISTRATION_REQUIRED:
return redirect( return redirect(
f'/register?redirect={redirect_page or request.url}', 307 f'/register?redirect={redirect_page or request.url}', 307
) )
if auth_status == AuthStatus.UNAUTHORIZED: if auth_status == UserAuthStatus.INVALID_CREDENTIALS:
return redirect(f'/login?redirect={redirect_page or request.url}', 307) return redirect(f'/login?redirect={redirect_page or request.url}', 307)
return Response( return Response(
@ -154,43 +167,67 @@ def authenticate(
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
def get_auth_status(req, skip_auth_methods=None) -> AuthStatus: def get_current_user_or_auth_status(
req, skip_auth_methods=None
) -> Union[User, UserAuthStatus]:
""" """
Check against the available authentication methods (except those listed in Returns the current user if authenticated, and the authentication status if
``skip_auth_methods``) if the user is properly authenticated. ``with_status`` is True.
""" """
n_users = user_manager.get_user_count() n_users = user_manager.get_user_count()
skip_methods = skip_auth_methods or [] skip_methods = skip_auth_methods or []
# User/pass HTTP authentication # User/pass HTTP authentication
http_auth_ok = True http_auth_ok = True
if n_users > 0 and 'http' not in skip_methods: if n_users > 0 and 'http' not in skip_methods:
http_auth_ok = authenticate_user_pass(req) response = authenticate_user_pass(req)
if http_auth_ok: if response:
return AuthStatus.OK user = response[0] if isinstance(response, tuple) else response
if user:
return user
# Token-based authentication # Token-based authentication
token_auth_ok = True token_auth_ok = True
if 'token' not in skip_methods: if 'token' not in skip_methods:
token_auth_ok = authenticate_token(req) user = authenticate_token(req)
if token_auth_ok: if user:
return AuthStatus.OK return user
# Session token based authentication # Session token based authentication
session_auth_ok = True session_auth_ok = True
if n_users > 0 and 'session' not in skip_methods: if n_users > 0 and 'session' not in skip_methods:
return AuthStatus.OK if authenticate_session(req) else AuthStatus.UNAUTHORIZED user = authenticate_session(req)
if user:
return user
return UserAuthStatus.INVALID_CREDENTIALS
# At least a user should be created before accessing an authenticated resource # At least a user should be created before accessing an authenticated resource
if n_users == 0 and 'session' not in skip_methods: if n_users == 0 and 'session' not in skip_methods:
return AuthStatus.NO_USERS return UserAuthStatus.REGISTRATION_REQUIRED
if ( # pylint: disable=too-many-boolean-expressions if ( # pylint: disable=too-many-boolean-expressions
('http' not in skip_methods and http_auth_ok) ('http' not in skip_methods and http_auth_ok)
or ('token' not in skip_methods and token_auth_ok) or ('token' not in skip_methods and token_auth_ok)
or ('session' not in skip_methods and session_auth_ok) or ('session' not in skip_methods and session_auth_ok)
): ):
return AuthStatus.OK return UserAuthStatus.OK
return AuthStatus.UNAUTHORIZED return UserAuthStatus.INVALID_CREDENTIALS
def get_auth_status(req, skip_auth_methods=None) -> UserAuthStatus:
"""
Check against the available authentication methods (except those listed in
``skip_auth_methods``) if the user is properly authenticated.
"""
ret = get_current_user_or_auth_status(req, skip_auth_methods=skip_auth_methods)
return UserAuthStatus.OK if isinstance(ret, User) else ret
def current_user() -> Optional[User]:
"""
Returns the current user if authenticated.
"""
ret = get_current_user_or_auth_status(request)
return ret if isinstance(ret, User) else None

View file

@ -1,21 +1,76 @@
from collections import namedtuple from collections import namedtuple
from enum import Enum from enum import Enum
from flask import jsonify
StatusValue = namedtuple('StatusValue', ['code', 'message']) from platypush.user import AuthenticationStatus
StatusValue = namedtuple('StatusValue', ['code', 'error', 'message'])
class AuthStatus(Enum): class UserAuthStatus(Enum):
""" """
Models the status of the authentication. Models the status of the authentication.
""" """
OK = StatusValue(200, 'OK') OK = StatusValue(200, AuthenticationStatus.OK, 'OK')
UNAUTHORIZED = StatusValue(401, 'Unauthorized') INVALID_AUTH_TYPE = StatusValue(
NO_USERS = StatusValue(412, 'Please create a user first') 400, AuthenticationStatus.INVALID_AUTH_TYPE, 'Invalid authentication type'
)
INVALID_CREDENTIALS = StatusValue(
401, AuthenticationStatus.INVALID_CREDENTIALS, 'Invalid credentials'
)
INVALID_JWT_TOKEN = StatusValue(
401, AuthenticationStatus.INVALID_JWT_TOKEN, 'Invalid JWT token'
)
INVALID_OTP_CODE = StatusValue(
401, AuthenticationStatus.INVALID_OTP_CODE, 'Invalid OTP code'
)
INVALID_METHOD = StatusValue(
405, AuthenticationStatus.INVALID_METHOD, 'Invalid method'
)
MISSING_OTP_CODE = StatusValue(
401, AuthenticationStatus.MISSING_OTP_CODE, 'Missing OTP code'
)
MISSING_PASSWORD = StatusValue(
400, AuthenticationStatus.MISSING_PASSWORD, 'Missing password'
)
INVALID_SESSION = StatusValue(
401, AuthenticationStatus.INVALID_CREDENTIALS, 'Invalid session'
)
INVALID_TOKEN = StatusValue(
400, AuthenticationStatus.INVALID_JWT_TOKEN, 'Invalid token'
)
MISSING_USERNAME = StatusValue(
400, AuthenticationStatus.MISSING_USERNAME, 'Missing username'
)
PASSWORD_MISMATCH = StatusValue(
400, AuthenticationStatus.PASSWORD_MISMATCH, 'Password mismatch'
)
REGISTRATION_DISABLED = StatusValue(
401, AuthenticationStatus.REGISTRATION_DISABLED, 'Registrations are disabled'
)
REGISTRATION_REQUIRED = StatusValue(
412, AuthenticationStatus.REGISTRATION_REQUIRED, 'Please create a user first'
)
UNKNOWN_ERROR = StatusValue(
500, AuthenticationStatus.UNKNOWN_ERROR, 'Unknown error'
)
def to_dict(self): def to_dict(self):
return { return {
'code': self.value[0], 'code': self.value[0],
'message': self.value[1], 'error': self.value[1].name,
'message': self.value[2],
} }
def to_response(self):
return jsonify(self.to_dict()), self.value[0]
@staticmethod
def by_status(status: AuthenticationStatus):
for auth_status in UserAuthStatus:
if auth_status.value[1] == status:
return auth_status
return None

View file

@ -5,7 +5,7 @@ from threading import Thread
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from tornado.websocket import WebSocketHandler from tornado.websocket import WebSocketHandler
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status from platypush.backend.http.app.utils.auth import UserAuthStatus, get_auth_status
from ..mixins import MessageType, PubSubMixin from ..mixins import MessageType, PubSubMixin
@ -25,7 +25,7 @@ class WSRoute(WebSocketHandler, Thread, PubSubMixin, ABC):
def open(self, *_, **__): def open(self, *_, **__):
auth_status = get_auth_status(self.request) auth_status = get_auth_status(self.request)
if auth_status != AuthStatus.OK: if auth_status != UserAuthStatus.OK:
self.close(code=1008, reason=auth_status.value.message) # Policy Violation self.close(code=1008, reason=auth_status.value.message) # Policy Violation
return return

View file

@ -1,7 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import hashlib import hashlib
import logging import logging
import os
from typing import Generator, Optional from typing import Generator, Optional
from platypush.message import JSONAble from platypush.message import JSONAble
@ -57,9 +56,6 @@ class MediaHandler(JSONAble, ABC):
logging.exception(e) logging.exception(e)
errors[hndl_class.__name__] = str(e) errors[hndl_class.__name__] = str(e)
if os.path.exists(source):
source = f'file://{source}'
raise AttributeError( raise AttributeError(
f'The source {source} has no handlers associated. Errors: {errors}' f'The source {source} has no handlers associated. Errors: {errors}'
) )

View file

@ -15,6 +15,9 @@ class FileHandler(MediaHandler):
prefix_handlers = ['file://'] prefix_handlers = ['file://']
def __init__(self, source, *args, **kwargs): def __init__(self, source, *args, **kwargs):
if isinstance(source, str) and os.path.exists(source):
source = f'file://{source}'
super().__init__(source, *args, **kwargs) super().__init__(source, *args, **kwargs)
self.path = os.path.abspath( self.path = os.path.abspath(
@ -33,7 +36,7 @@ class FileHandler(MediaHandler):
), f'{source} is not a valid media file (detected format: {self.mime_type})' ), f'{source} is not a valid media file (detected format: {self.mime_type})'
self.extension = mimetypes.guess_extension(self.mime_type) self.extension = mimetypes.guess_extension(self.mime_type)
if self.url and self.extension: if self.url and self.extension and not self.url.endswith(self.extension):
self.url += self.extension self.url += self.extension
self.content_length = os.path.getsize(self.path) self.content_length = os.path.getsize(self.path)

View file

@ -7,6 +7,7 @@ import re
from platypush.config import Config from platypush.config import Config
from platypush.backend.http.app import template_folder from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import current_user
class HttpUtils: class HttpUtils:
@ -130,5 +131,9 @@ class HttpUtils:
path = path[0] if len(path) == 1 else os.path.join(*path) path = path[0] if len(path) == 1 else os.path.join(*path)
return os.path.isfile(path) return os.path.isfile(path)
@staticmethod
def current_user():
return current_user()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.05911ac4.js"></script><script defer="defer" src="/static/js/app.a9d9b93f.js"></script><link href="/static/css/chunk-vendors.d510eff2.css" rel="stylesheet"><link href="/static/css/app.34a0a3ba.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.5645dfdc.js"></script><script defer="defer" src="/static/js/app.d90fb573.js"></script><link href="/static/css/chunk-vendors.d510eff2.css" rel="stylesheet"><link href="/static/css/app.70fb1f4a.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more