From 6ad4a2459812cb9cbb2dae56ce1ae7b6960455a9 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Wed, 12 Jun 2019 23:29:14 +0600 Subject: [PATCH 01/12] Move auth code to auth.py. Introduce abstract Auth class, BasicAuth providing HTTP Authentication and its dummy subclass WoTTAuth. Move assetdir initialization to a separate function run by Flask. --- auth.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 52 ++++++++++++++++++++++++------------------------ settings.py | 30 +++++----------------------- 3 files changed, 88 insertions(+), 51 deletions(-) create mode 100644 auth.py diff --git a/auth.py b/auth.py new file mode 100644 index 000000000..fac4ee618 --- /dev/null +++ b/auth.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from abc import ABCMeta, abstractmethod +from functools import wraps +import hashlib + +from flask import request, Response + + +class Auth(object): + __metaclass__ = ABCMeta + + @abstractmethod + def authenticate(self): + pass + + @abstractmethod + def is_authorized(self): + pass + + def auth(self): + if not self.is_authorized: + return self.authenticate() + + +class BasicAuth(Auth): + def __init__(self, settings): + self.settings = settings + + def _check(self, username, password): + hashed_password = hashlib.sha256(password).hexdigest() + return self.settings['user'] == username and self.settings['password'] == hashed_password + + @property + def is_authorized(self): + auth = request.authorization + return auth and self._check(auth.username, auth.password) + + def authenticate(self): + realm = "Screenly OSE {}".format(self.settings['player_name']) + return Response("Access denied", 401, {"WWW-Authenticate": 'Basic realm="{}"'.format(realm)}) + + +class WoTTAuth(BasicAuth): + def __init__(self, settings): + super(WoTTAuth, self).__init__(settings) + + +def authorized(orig): + from settings import settings + + @wraps(orig) + def decorated(*args, **kwargs): + if not settings.auth: + return orig(*args, **kwargs) + return settings.auth.auth() or orig(*args, **kwargs) + return decorated diff --git a/server.py b/server.py index e73177e43..02e2f0b5b 100755 --- a/server.py +++ b/server.py @@ -43,7 +43,8 @@ from lib.utils import validate_url from lib.utils import is_balena_app, is_demo_node -from settings import auth_basic, CONFIGURABLE_SETTINGS, DEFAULTS, LISTEN, PORT, settings, ZmqPublisher, ZmqCollector +from settings import CONFIGURABLE_SETTINGS, DEFAULTS, LISTEN, PORT, settings, ZmqPublisher, ZmqCollector +from auth import authorized HOME = getenv('HOME', '/home/pi') @@ -406,7 +407,7 @@ def api_view(*args, **kwargs): class Assets(Resource): - method_decorators = [auth_basic] + method_decorators = [authorized] @swagger.doc({ 'responses': { @@ -469,7 +470,7 @@ def post(self): class Asset(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -563,7 +564,7 @@ def delete(self, asset_id): class AssetsV1_1(Resource): - method_decorators = [auth_basic] + method_decorators = [authorized] @swagger.doc({ 'responses': { @@ -609,7 +610,7 @@ def post(self): class AssetV1_1(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -689,7 +690,7 @@ def delete(self, asset_id): class AssetsV1_2(Resource): - method_decorators = [auth_basic] + method_decorators = [authorized] @swagger.doc({ 'responses': { @@ -742,7 +743,7 @@ def post(self): class AssetV1_2(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -836,7 +837,7 @@ def delete(self, asset_id): class FileAsset(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -883,7 +884,7 @@ def post(self): class PlaylistOrder(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -911,7 +912,7 @@ def post(self): class Backup(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { @@ -929,7 +930,7 @@ def post(self): class Recover(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -960,7 +961,7 @@ def post(self): class ResetWifiConfig(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { @@ -1006,7 +1007,7 @@ def get(self): class Info(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] def get(self): viewlog = None @@ -1030,7 +1031,7 @@ def get(self): class AssetsControl(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -1060,7 +1061,7 @@ def get(self, command): class AssetContent(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ @@ -1119,7 +1120,7 @@ def get(self, asset_id): class ViewerCurrentAsset(Resource): - method_decorators = [api_response, auth_basic] + method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { @@ -1192,7 +1193,7 @@ def get(self): @app.route('/') -@auth_basic +@authorized def viewIndex(): player_name = settings['player_name'] my_ip = urlparse(request.host_url).hostname @@ -1213,7 +1214,7 @@ def viewIndex(): @app.route('/settings', methods=["GET", "POST"]) -@auth_basic +@authorized def settings_page(): context = {'flash': None} @@ -1272,10 +1273,6 @@ def settings_page(): for field, default in CONFIGURABLE_SETTINGS.items(): value = request.form.get(field, default) - # skip user and password as they should be handled already. - if field == "user" or field == "password": - continue - if not value and field in ['default_duration', 'default_streaming_duration']: value = str(0) @@ -1312,7 +1309,7 @@ def settings_page(): @app.route('/system-info') -@auth_basic +@authorized def system_info(): viewlog = None try: @@ -1362,7 +1359,7 @@ def system_info(): @app.route('/integrations') -@auth_basic +@authorized def integrations(): context = { @@ -1434,13 +1431,14 @@ def dated_url_for(endpoint, **values): @app.route('/static_with_mime/') -@auth_basic +@authorized def static_with_mime(path): mimetype = request.args['mime'] if 'mime' in request.args else 'auto' return send_from_directory(directory='static', filename=path, mimetype=mimetype) -if __name__ == "__main__": +@app.before_first_request +def main(): # Make sure the asset folder exist. If not, create it if not path.isdir(settings['assetdir']): mkdir(settings['assetdir']) @@ -1454,6 +1452,8 @@ def static_with_mime(path): if cursor.fetchone() is None: cursor.execute(assets_helper.create_assets_table) + +if __name__ == "__main__": config = { 'bind': '{}:{}'.format(LISTEN, PORT), 'threads': 2, diff --git a/settings.py b/settings.py index 919709ccb..3bf42d1ce 100644 --- a/settings.py +++ b/settings.py @@ -12,6 +12,7 @@ import hashlib from lib.errors import ZmqCollectorTimeout +from auth import WoTTAuth CONFIG_DIR = '.screenly/' CONFIG_FILE = 'screenly.conf' @@ -42,8 +43,6 @@ } } CONFIGURABLE_SETTINGS = DEFAULTS['viewer'].copy() -CONFIGURABLE_SETTINGS['user'] = DEFAULTS['auth']['user'] -CONFIGURABLE_SETTINGS['password'] = DEFAULTS['auth']['password'] CONFIGURABLE_SETTINGS['use_24_hour_clock'] = DEFAULTS['main']['use_24_hour_clock'] CONFIGURABLE_SETTINGS['date_format'] = DEFAULTS['main']['date_format'] @@ -70,6 +69,7 @@ def __init__(self, *args, **kwargs): IterableUserDict.__init__(self, *args, **kwargs) self.home = getenv('HOME') self.conf_file = self.get_configfile() + self._auth = WoTTAuth(self) if not path.isfile(self.conf_file): logging.error('Config-file %s missing. Using defaults.', self.conf_file) @@ -132,12 +132,9 @@ def get_configdir(self): def get_configfile(self): return path.join(self.home, CONFIG_DIR, CONFIG_FILE) - def check_user(self, user, password): - if not self['user'] or not self['password']: - logging.debug('Username or password not configured: skip authentication') - return True - - return self['user'] == user and self['password'] == password + @property + def auth(self): + return self._auth settings = ScreenlySettings() @@ -211,20 +208,3 @@ def recv_json(self, timeout): return json.loads(self.socket.recv(zmq.NOBLOCK)) raise ZmqCollectorTimeout - - -def authenticate(): - realm = "Screenly OSE" + (" " + settings['player_name'] if settings['player_name'] else "") - return Response("Access denied", 401, {"WWW-Authenticate": 'Basic realm="' + realm + '"'}) - - -def auth_basic(orig): - @wraps(orig) - def decorated(*args, **kwargs): - if not settings['user'] or not settings['password']: - return orig(*args, **kwargs) - auth = request.authorization - if not auth or not settings.check_user(auth.username, hashlib.sha256(auth.password).hexdigest()): - return authenticate() - return orig(*args, **kwargs) - return decorated From 0a889d0b30a3b3cd5fb5001f36a0724bb84852b3 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Wed, 12 Jun 2019 23:44:10 +0600 Subject: [PATCH 02/12] Add docs to Auth class. --- auth.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/auth.py b/auth.py index fac4ee618..b51263df5 100644 --- a/auth.py +++ b/auth.py @@ -12,13 +12,25 @@ class Auth(object): @abstractmethod def authenticate(self): + """ + Let the user authenticate himself. + :return: a Response which initiates authentication. + """ pass @abstractmethod def is_authorized(self): + """ + See if the user is authorized for the request. + :return: bool + """ pass - def auth(self): + def authorize(self): + """ + If the request is not authorized, let the user authenticate himself. + :return: a Response which initiates authentication or None if authorized. + """ if not self.is_authorized: return self.authenticate() @@ -53,5 +65,5 @@ def authorized(orig): def decorated(*args, **kwargs): if not settings.auth: return orig(*args, **kwargs) - return settings.auth.auth() or orig(*args, **kwargs) + return settings.auth.authorize() or orig(*args, **kwargs) return decorated From 590a0693e29c32f8d25c3625d6c9cee6f7bc512b Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Thu, 13 Jun 2019 11:08:03 +0600 Subject: [PATCH 03/12] More docstrings and TODOs for WoTTAuth. --- auth.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/auth.py b/auth.py index b51263df5..acd2ffd23 100644 --- a/auth.py +++ b/auth.py @@ -40,6 +40,12 @@ def __init__(self, settings): self.settings = settings def _check(self, username, password): + """ + Check username/password combo against database. + :param username: str + :param password: str + :return: True if the check passes. + """ hashed_password = hashlib.sha256(password).hexdigest() return self.settings['user'] == username and self.settings['password'] == hashed_password @@ -56,9 +62,19 @@ def authenticate(self): class WoTTAuth(BasicAuth): def __init__(self, settings): super(WoTTAuth, self).__init__(settings) + # TODO: read credentials, store them into self.username and self.password + + def _check(self, username, password): + # TODO: compare username and password with self.username and self.password + return super(WoTTAuth, self)._check(username, password) def authorized(orig): + """ + Annotation which initiates authentication if the request is unauthorized. + :param orig: Flask function + :return: Response + """ from settings import settings @wraps(orig) From 0c1f18c3ecacf74fa1164bd6d06d75a49012c3fa Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Thu, 13 Jun 2019 11:39:17 +0600 Subject: [PATCH 04/12] Configurable auth backend in ScreenlySettings. Let Auth classes specify their config. --- auth.py | 17 +++++++++++++++++ settings.py | 16 +++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/auth.py b/auth.py index acd2ffd23..51f9f1ef3 100644 --- a/auth.py +++ b/auth.py @@ -36,6 +36,15 @@ def authorize(self): class BasicAuth(Auth): + @classmethod + def config(cls): + return { + 'auth': { + 'user': '', + 'password': '' + } + } + def __init__(self, settings): self.settings = settings @@ -60,6 +69,14 @@ def authenticate(self): class WoTTAuth(BasicAuth): + @classmethod + def config(cls): + return { + 'wott': { + # TODO: return real settings + } + } + def __init__(self, settings): super(WoTTAuth, self).__init__(settings) # TODO: read credentials, store them into self.username and self.password diff --git a/settings.py b/settings.py index 3bf42d1ce..f227f28c4 100644 --- a/settings.py +++ b/settings.py @@ -12,7 +12,7 @@ import hashlib from lib.errors import ZmqCollectorTimeout -from auth import WoTTAuth +from auth import WoTTAuth, BasicAuth CONFIG_DIR = '.screenly/' CONFIG_FILE = 'screenly.conf' @@ -24,6 +24,7 @@ 'date_format': 'mm/dd/yyyy', 'use_24_hour_clock': False, 'use_ssl': False, + 'auth_backend': 'auth', 'websocket_port': '9999' }, 'viewer': { @@ -36,10 +37,6 @@ 'show_splash': True, 'shuffle_playlist': False, 'verify_ssl': True - }, - 'auth': { - 'user': '', - 'password': '' } } CONFIGURABLE_SETTINGS = DEFAULTS['viewer'].copy() @@ -69,7 +66,12 @@ def __init__(self, *args, **kwargs): IterableUserDict.__init__(self, *args, **kwargs) self.home = getenv('HOME') self.conf_file = self.get_configfile() - self._auth = WoTTAuth(self) + auth_backends = [BasicAuth(self), WoTTAuth(self)] + self.auth_backends = {} + for b in auth_backends: + c = b.config() + DEFAULTS.update(c) + self.auth_backends[c.keys()[0]] = b if not path.isfile(self.conf_file): logging.error('Config-file %s missing. Using defaults.', self.conf_file) @@ -134,7 +136,7 @@ def get_configfile(self): @property def auth(self): - return self._auth + return self.auth_backends[ self['auth_backend'] ] settings = ScreenlySettings() From 5e3f0d3c2562138bcdd0e340ade467b973e07fbf Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Thu, 13 Jun 2019 23:41:02 +0600 Subject: [PATCH 05/12] Rename auth backend config groups. Rewrite auth settings update procedure. --- ansible/roles/screenly/files/screenly.conf | 7 +- auth.py | 4 +- server.py | 93 +++++++++++----------- settings.py | 6 +- templates/settings.html | 2 +- 5 files changed, 58 insertions(+), 54 deletions(-) diff --git a/ansible/roles/screenly/files/screenly.conf b/ansible/roles/screenly/files/screenly.conf index 9137f847e..1e6eaae4b 100644 --- a/ansible/roles/screenly/files/screenly.conf +++ b/ansible/roles/screenly/files/screenly.conf @@ -36,7 +36,10 @@ verify_ssl = True ; Run Resin wifi connect if there is no connection enable_offline_mode = False -[auth] -; If desired, fill in with appropriate username and password +; Set to 'auth_basic' to use HTTP Basic Authentication (see below) +auth_backend = + +[auth_basic] +; Fill in with appropriate username and password user= password= diff --git a/auth.py b/auth.py index 51f9f1ef3..6baa8798e 100644 --- a/auth.py +++ b/auth.py @@ -39,7 +39,7 @@ class BasicAuth(Auth): @classmethod def config(cls): return { - 'auth': { + 'auth_basic': { 'user': '', 'password': '' } @@ -72,7 +72,7 @@ class WoTTAuth(BasicAuth): @classmethod def config(cls): return { - 'wott': { + 'auth_wott': { # TODO: return real settings } } diff --git a/server.py b/server.py index 02e2f0b5b..1ba0d37f5 100755 --- a/server.py +++ b/server.py @@ -1227,48 +1227,49 @@ def settings_page(): current_pass = '' if current_pass == '' else hashlib.sha256(current_pass).hexdigest() new_pass = '' if new_pass == '' else hashlib.sha256(new_pass).hexdigest() new_pass2 = '' if new_pass2 == '' else hashlib.sha256(new_pass2).hexdigest() + current_pass_correct = current_pass == settings['password'] new_user = request.form.get('user', '') use_auth = request.form.get('use_auth', '') == 'on' - # Handle auth components - if settings['password'] != '': # if password currently set, - if new_user != settings['user']: # trying to change user - # should have current password set. Optionally may change password. - if current_pass == '': - if not use_auth: - raise ValueError("Must supply current password to disable authentication") - raise ValueError("Must supply current password to change username") - if current_pass != settings['password']: - raise ValueError("Incorrect current password.") - - settings['user'] = new_user - - if new_pass != '' and use_auth: - if current_pass == '': - raise ValueError("Must supply current password to change password") - if current_pass != settings['password']: - raise ValueError("Incorrect current password.") - - if new_pass2 != new_pass: # changing password - raise ValueError("New passwords do not match!") - - settings['password'] = new_pass - - if new_pass == '' and not use_auth and new_pass2 == '': - # trying to disable authentication - if current_pass == '': - raise ValueError("Must supply current password to disable authentication") - settings['password'] = '' - - else: # no current password - if new_user != '': # setting username and password - if new_pass != '' and new_pass != new_pass2: - raise ValueError("New passwords do not match!") - if new_pass == '': - raise ValueError("Must provide password") - settings['user'] = new_user - settings['password'] = new_pass + if use_auth: + # Handle auth components + if settings['password'] != '': # if password currently set, + if new_user != settings['user']: # trying to change user + # should have current password set. Optionally may change password. + if not current_pass: + raise ValueError("Must supply current password to change username") + if not current_pass_correct: + raise ValueError("Incorrect current password.") + + settings['user'] = new_user + + if new_pass != '': + if not current_pass: + raise ValueError("Must supply current password to change password") + if not current_pass_correct: + raise ValueError("Incorrect current password.") + + if new_pass2 != new_pass: # changing password + raise ValueError("New passwords do not match!") + + settings['password'] = new_pass + + else: # no current password + if new_user != '': # setting username and password + if new_pass != '' and new_pass != new_pass2: + raise ValueError("New passwords do not match!") + if new_pass == '': + raise ValueError("Must provide password") + settings['user'] = new_user + settings['password'] = new_pass + else: + raise ValueError("Must provide username") + elif settings['auth_backend'] != '': + if not current_pass: + raise ValueError("Must supply current password to disable authentication") + if not current_pass_correct: + raise ValueError("Incorrect current password.") for field, default in CONFIGURABLE_SETTINGS.items(): value = request.form.get(field, default) @@ -1280,6 +1281,7 @@ def settings_page(): value = value == 'on' settings[field] = value + settings['auth_backend'] = 'auth_basic' if use_auth else '' settings.save() publisher = ZmqPublisher.get_instance() publisher.send_to_viewer('reload') @@ -1295,15 +1297,12 @@ def settings_page(): for field, default in DEFAULTS['viewer'].items(): context[field] = settings[field] - context['user'] = settings['user'] - context['password'] = "password" if settings['password'] != "" else "" - - context['is_balena'] = is_balena_app() - - if not settings['user'] or not settings['password']: - context['use_auth'] = False - else: - context['use_auth'] = True + context.update({ + 'user': settings['user'], + 'need_current_password': settings['password'] != "", + 'is_balena': is_balena_app(), + 'use_auth': bool(settings['auth_backend']) + }) return template('settings.html', **context) diff --git a/settings.py b/settings.py index f227f28c4..43c9f464b 100644 --- a/settings.py +++ b/settings.py @@ -24,7 +24,7 @@ 'date_format': 'mm/dd/yyyy', 'use_24_hour_clock': False, 'use_ssl': False, - 'auth_backend': 'auth', + 'auth_backend': '', 'websocket_port': '9999' }, 'viewer': { @@ -136,7 +136,9 @@ def get_configfile(self): @property def auth(self): - return self.auth_backends[ self['auth_backend'] ] + backend_name = self['auth_backend'] + if backend_name in self.auth_backends: + return self.auth_backends[ self['auth_backend'] ] settings = ScreenlySettings() diff --git a/templates/settings.html b/templates/settings.html index 98dee733f..f52677a05 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -196,7 +196,7 @@

- {% if context.password != "" %} + {% if context.need_current_password %}
From 0531b6db6a5d333b13e6cd9b2fddd4302035f47f Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 11:45:19 +0600 Subject: [PATCH 06/12] Fix pep8 warnings. --- settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/settings.py b/settings.py index 43c9f464b..e5dbc0e0e 100644 --- a/settings.py +++ b/settings.py @@ -6,8 +6,6 @@ import ConfigParser import logging from UserDict import IterableUserDict -from flask import request, Response -from functools import wraps import zmq import hashlib @@ -138,7 +136,7 @@ def get_configfile(self): def auth(self): backend_name = self['auth_backend'] if backend_name in self.auth_backends: - return self.auth_backends[ self['auth_backend'] ] + return self.auth_backends[self['auth_backend']] settings = ScreenlySettings() From da05e74fbc2b18b8a878c31b20882ea7f0ebb0d6 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 14:07:58 +0600 Subject: [PATCH 07/12] Auth backend select UI. --- server.py | 15 ++++++++------- static/js/settings.coffee | 10 ++++------ static/js/settings.js | 12 ++++-------- templates/settings.html | 19 ++++++------------- 4 files changed, 22 insertions(+), 34 deletions(-) diff --git a/server.py b/server.py index 1ba0d37f5..f86a1b15c 100755 --- a/server.py +++ b/server.py @@ -1230,9 +1230,9 @@ def settings_page(): current_pass_correct = current_pass == settings['password'] new_user = request.form.get('user', '') - use_auth = request.form.get('use_auth', '') == 'on' + auth_backend = request.form.get('auth_backend', '') - if use_auth: + if auth_backend == 'auth_basic': # Handle auth components if settings['password'] != '': # if password currently set, if new_user != settings['user']: # trying to change user @@ -1265,9 +1265,10 @@ def settings_page(): settings['password'] = new_pass else: raise ValueError("Must provide username") - elif settings['auth_backend'] != '': + + if auth_backend != settings['auth_backend'] and settings['auth_backend']: if not current_pass: - raise ValueError("Must supply current password to disable authentication") + raise ValueError("Must supply current password to change authentication method") if not current_pass_correct: raise ValueError("Incorrect current password.") @@ -1281,7 +1282,7 @@ def settings_page(): value = value == 'on' settings[field] = value - settings['auth_backend'] = 'auth_basic' if use_auth else '' + settings['auth_backend'] = auth_backend settings.save() publisher = ZmqPublisher.get_instance() publisher.send_to_viewer('reload') @@ -1299,9 +1300,9 @@ def settings_page(): context.update({ 'user': settings['user'], - 'need_current_password': settings['password'] != "", + 'need_current_password': bool(settings['auth_backend']), 'is_balena': is_balena_app(), - 'use_auth': bool(settings['auth_backend']) + 'auth_backend': settings['auth_backend'] }) return template('settings.html', **context) diff --git a/static/js/settings.coffee b/static/js/settings.coffee index def0c555b..1925c5511 100644 --- a/static/js/settings.coffee +++ b/static/js/settings.coffee @@ -80,8 +80,8 @@ $().ready -> .error (e) -> document.location.reload() - $('#auth_checkbox p span').click (e) -> - if $('input:checkbox[name="use_auth"]').is(':checked') + $('#auth_backend').change (e) -> + if $('#auth_backend').val() == '' $('#user_group, #password_group, #password2_group').hide() $('input:text[name="user"]').val('') $('input:password[name="password"]').val('') @@ -89,7 +89,5 @@ $().ready -> else $('#user_group, #password_group, #password2_group, #curpassword_group').show() - if $('input:checkbox[name="use_auth"]').is(':checked') - $('#user_group, #password_group, #password2_group, #curpassword_group').show() - else - $('#user_group, #password_group, #password2_group, #curpassword_group').hide() + $('#user_group, #password_group, #password2_group, #curpassword_group').toggle $('#auth_backend').val() != '' + diff --git a/static/js/settings.js b/static/js/settings.js index 1b570fa81..6d088ed2a 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.9.3 +// Generated by CoffeeScript 2.4.1 (function() { $().ready(function() { $('#request-error .close').click(function(e) { @@ -90,8 +90,8 @@ return document.location.reload(); }); }); - $('#auth_checkbox p span').click(function(e) { - if ($('input:checkbox[name="use_auth"]').is(':checked')) { + $('#auth_backend').change(function(e) { + if ($('#auth_backend').val() === '') { $('#user_group, #password_group, #password2_group').hide(); $('input:text[name="user"]').val(''); $('input:password[name="password"]').val(''); @@ -100,11 +100,7 @@ return $('#user_group, #password_group, #password2_group, #curpassword_group').show(); } }); - if ($('input:checkbox[name="use_auth"]').is(':checked')) { - return $('#user_group, #password_group, #password2_group, #curpassword_group').show(); - } else { - return $('#user_group, #password_group, #password2_group, #curpassword_group').hide(); - } + return $('#user_group, #password_group, #password2_group, #curpassword_group').toggle($('#auth_backend').val() !== ''); }); }).call(this); diff --git a/templates/settings.html b/templates/settings.html index f52677a05..7ac4befb5 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -174,20 +174,13 @@

- +
- +
From 47841469a0ca74bef90fb23fb4433895e33d8bb5 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 15:10:00 +0600 Subject: [PATCH 08/12] Pass list of auth backends to settings template. Add NoAuth backend ("Disabled"). --- auth.py | 24 ++++++++++++++++++------ server.py | 8 +++++++- settings.py | 10 +++++----- templates/settings.html | 6 +++--- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/auth.py b/auth.py index 6baa8798e..4f64146bc 100644 --- a/auth.py +++ b/auth.py @@ -35,10 +35,22 @@ def authorize(self): return self.authenticate() +class NoAuth(Auth): + name = 'Disabled' + id = '' + config = {} + + def is_authorized(self): + return True + + def authenticate(self): + pass + + class BasicAuth(Auth): - @classmethod - def config(cls): - return { + name = 'Basic' + id = 'auth_basic' + config = { 'auth_basic': { 'user': '', 'password': '' @@ -69,9 +81,9 @@ def authenticate(self): class WoTTAuth(BasicAuth): - @classmethod - def config(cls): - return { + name = 'WoTT' + id = 'auth_wott' + config = { 'auth_wott': { # TODO: return real settings } diff --git a/server.py b/server.py index f86a1b15c..bf11b2ff5 100755 --- a/server.py +++ b/server.py @@ -1298,11 +1298,17 @@ def settings_page(): for field, default in DEFAULTS['viewer'].items(): context[field] = settings[field] + auth_backends = [{ + 'name': backend.id, + 'text': backend.name, + 'selected': 'selected' if settings['auth_backend'] == backend.id else ''} + for backend in settings.auth_backends_list] context.update({ 'user': settings['user'], 'need_current_password': bool(settings['auth_backend']), 'is_balena': is_balena_app(), - 'auth_backend': settings['auth_backend'] + 'auth_backend': settings['auth_backend'], + 'auth_backends': auth_backends }) return template('settings.html', **context) diff --git a/settings.py b/settings.py index e5dbc0e0e..932f92bcf 100644 --- a/settings.py +++ b/settings.py @@ -10,7 +10,7 @@ import hashlib from lib.errors import ZmqCollectorTimeout -from auth import WoTTAuth, BasicAuth +from auth import WoTTAuth, BasicAuth, NoAuth CONFIG_DIR = '.screenly/' CONFIG_FILE = 'screenly.conf' @@ -64,12 +64,12 @@ def __init__(self, *args, **kwargs): IterableUserDict.__init__(self, *args, **kwargs) self.home = getenv('HOME') self.conf_file = self.get_configfile() - auth_backends = [BasicAuth(self), WoTTAuth(self)] + self.auth_backends_list = [NoAuth(), BasicAuth(self), WoTTAuth(self)] self.auth_backends = {} - for b in auth_backends: - c = b.config() + for b in self.auth_backends_list: + c = b.config DEFAULTS.update(c) - self.auth_backends[c.keys()[0]] = b + self.auth_backends[b.id] = b if not path.isfile(self.conf_file): logging.error('Config-file %s missing. Using defaults.', self.conf_file) diff --git a/templates/settings.html b/templates/settings.html index 7ac4befb5..013df32b5 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -177,9 +177,9 @@

From 5417d1c14b735509f19071e6162acd8677d8c3c5 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 15:16:16 +0600 Subject: [PATCH 09/12] Only show username/password settings for auth_basic. --- static/js/settings.coffee | 4 ++-- static/js/settings.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/static/js/settings.coffee b/static/js/settings.coffee index 1925c5511..be95de06a 100644 --- a/static/js/settings.coffee +++ b/static/js/settings.coffee @@ -81,7 +81,7 @@ $().ready -> document.location.reload() $('#auth_backend').change (e) -> - if $('#auth_backend').val() == '' + if $('#auth_backend').val() != 'auth_basic' $('#user_group, #password_group, #password2_group').hide() $('input:text[name="user"]').val('') $('input:password[name="password"]').val('') @@ -89,5 +89,5 @@ $().ready -> else $('#user_group, #password_group, #password2_group, #curpassword_group').show() - $('#user_group, #password_group, #password2_group, #curpassword_group').toggle $('#auth_backend').val() != '' + $('#user_group, #password_group, #password2_group').toggle $('#auth_backend').val() == 'auth_basic' diff --git a/static/js/settings.js b/static/js/settings.js index 6d088ed2a..8929cd4d2 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -91,7 +91,7 @@ }); }); $('#auth_backend').change(function(e) { - if ($('#auth_backend').val() === '') { + if ($('#auth_backend').val() !== 'auth_basic') { $('#user_group, #password_group, #password2_group').hide(); $('input:text[name="user"]').val(''); $('input:password[name="password"]').val(''); @@ -100,7 +100,7 @@ return $('#user_group, #password_group, #password2_group, #curpassword_group').show(); } }); - return $('#user_group, #password_group, #password2_group, #curpassword_group').toggle($('#auth_backend').val() !== ''); + return $('#user_group, #password_group, #password2_group').toggle($('#auth_backend').val() === 'auth_basic'); }); }).call(this); From 4e93a37aeb8766cf557d8c64bca7e2659129a4bf Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 16:20:12 +0600 Subject: [PATCH 10/12] Fix pep8 warnings. --- auth.py | 14 +++++++------- server.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/auth.py b/auth.py index 4f64146bc..6efb46760 100644 --- a/auth.py +++ b/auth.py @@ -51,11 +51,11 @@ class BasicAuth(Auth): name = 'Basic' id = 'auth_basic' config = { - 'auth_basic': { - 'user': '', - 'password': '' - } + 'auth_basic': { + 'user': '', + 'password': '' } + } def __init__(self, settings): self.settings = settings @@ -84,10 +84,10 @@ class WoTTAuth(BasicAuth): name = 'WoTT' id = 'auth_wott' config = { - 'auth_wott': { - # TODO: return real settings - } + 'auth_wott': { + # TODO: return real settings } + } def __init__(self, settings): super(WoTTAuth, self).__init__(settings) diff --git a/server.py b/server.py index bf11b2ff5..097dfc957 100755 --- a/server.py +++ b/server.py @@ -1302,7 +1302,7 @@ def settings_page(): 'name': backend.id, 'text': backend.name, 'selected': 'selected' if settings['auth_backend'] == backend.id else ''} - for backend in settings.auth_backends_list] + for backend in settings.auth_backends_list] context.update({ 'user': settings['user'], 'need_current_password': bool(settings['auth_backend']), From f29753526eb2ee5b921c1db7e76aed096acfef8c Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 19:11:20 +0600 Subject: [PATCH 11/12] Make auth backends UI-modular. --- auth.py | 64 +++++++++++++++++++++++++++++++++++-- server.py | 67 +++++++++++---------------------------- static/js/settings.coffee | 17 +++++----- static/js/settings.js | 19 +++++------ templates/auth_basic.html | 18 +++++++++++ templates/settings.html | 32 +++++++------------ 6 files changed, 126 insertions(+), 91 deletions(-) create mode 100644 templates/auth_basic.html diff --git a/auth.py b/auth.py index 6efb46760..603f5f2ee 100644 --- a/auth.py +++ b/auth.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from abc import ABCMeta, abstractmethod +from abc import ABCMeta, abstractmethod, abstractproperty from functools import wraps import hashlib @@ -18,7 +18,7 @@ def authenticate(self): """ pass - @abstractmethod + @abstractproperty def is_authorized(self): """ See if the user is authorized for the request. @@ -34,6 +34,13 @@ def authorize(self): if not self.is_authorized: return self.authenticate() + def update_settings(self, current_password): + pass + + @property + def template(self): + pass + class NoAuth(Auth): name = 'Disabled' @@ -67,18 +74,65 @@ def _check(self, username, password): :param password: str :return: True if the check passes. """ + return self.settings['user'] == username and self.check_password(password) + + def check_password(self, password): hashed_password = hashlib.sha256(password).hexdigest() - return self.settings['user'] == username and self.settings['password'] == hashed_password + return self.settings['password'] == hashed_password @property def is_authorized(self): auth = request.authorization return auth and self._check(auth.username, auth.password) + @property + def template(self): + return 'auth_basic.html', {'user': self.settings['user']} + def authenticate(self): realm = "Screenly OSE {}".format(self.settings['player_name']) return Response("Access denied", 401, {"WWW-Authenticate": 'Basic realm="{}"'.format(realm)}) + def update_settings(self, current_pass): + current_pass_correct = self.check_password(current_pass) + new_user = request.form.get('user', '') + new_pass = request.form.get('password', '') + new_pass2 = request.form.get('password2', '') + new_pass = '' if new_pass == '' else hashlib.sha256(new_pass).hexdigest() + new_pass2 = '' if new_pass2 == '' else hashlib.sha256(new_pass2).hexdigest() + # Handle auth components + if self.settings['password'] != '': # if password currently set, + if new_user != self.settings['user']: # trying to change user + # should have current password set. Optionally may change password. + if not current_pass: + raise ValueError("Must supply current password to change username") + if not current_pass_correct: + raise ValueError("Incorrect current password.") + + self.settings['user'] = new_user + + if new_pass != '': + if not current_pass: + raise ValueError("Must supply current password to change password") + if not current_pass_correct: + raise ValueError("Incorrect current password.") + + if new_pass2 != new_pass: # changing password + raise ValueError("New passwords do not match!") + + self.settings['password'] = new_pass + + else: # no current password + if new_user != '': # setting username and password + if new_pass != '' and new_pass != new_pass2: + raise ValueError("New passwords do not match!") + if new_pass == '': + raise ValueError("Must provide password") + self.settings['user'] = new_user + self.settings['password'] = new_pass + else: + raise ValueError("Must provide username") + class WoTTAuth(BasicAuth): name = 'WoTT' @@ -97,6 +151,10 @@ def _check(self, username, password): # TODO: compare username and password with self.username and self.password return super(WoTTAuth, self)._check(username, password) + @property + def template(self): + return None + def authorized(orig): """ diff --git a/server.py b/server.py index 097dfc957..94bf86b86 100755 --- a/server.py +++ b/server.py @@ -1222,56 +1222,17 @@ def settings_page(): try: # put some request variables in local variables to make easier to read current_pass = request.form.get('curpassword', '') - new_pass = request.form.get('password', '') - new_pass2 = request.form.get('password2', '') - current_pass = '' if current_pass == '' else hashlib.sha256(current_pass).hexdigest() - new_pass = '' if new_pass == '' else hashlib.sha256(new_pass).hexdigest() - new_pass2 = '' if new_pass2 == '' else hashlib.sha256(new_pass2).hexdigest() - current_pass_correct = current_pass == settings['password'] - - new_user = request.form.get('user', '') auth_backend = request.form.get('auth_backend', '') - if auth_backend == 'auth_basic': - # Handle auth components - if settings['password'] != '': # if password currently set, - if new_user != settings['user']: # trying to change user - # should have current password set. Optionally may change password. - if not current_pass: - raise ValueError("Must supply current password to change username") - if not current_pass_correct: - raise ValueError("Incorrect current password.") - - settings['user'] = new_user - - if new_pass != '': - if not current_pass: - raise ValueError("Must supply current password to change password") - if not current_pass_correct: - raise ValueError("Incorrect current password.") - - if new_pass2 != new_pass: # changing password - raise ValueError("New passwords do not match!") - - settings['password'] = new_pass - - else: # no current password - if new_user != '': # setting username and password - if new_pass != '' and new_pass != new_pass2: - raise ValueError("New passwords do not match!") - if new_pass == '': - raise ValueError("Must provide password") - settings['user'] = new_user - settings['password'] = new_pass - else: - raise ValueError("Must provide username") - if auth_backend != settings['auth_backend'] and settings['auth_backend']: if not current_pass: raise ValueError("Must supply current password to change authentication method") - if not current_pass_correct: + if not settings.auth.check_password(current_pass): raise ValueError("Incorrect current password.") + settings['auth_backend'] = auth_backend + settings.auth.update_settings(current_pass) + for field, default in CONFIGURABLE_SETTINGS.items(): value = request.form.get(field, default) @@ -1282,7 +1243,6 @@ def settings_page(): value = value == 'on' settings[field] = value - settings['auth_backend'] = auth_backend settings.save() publisher = ZmqPublisher.get_instance() publisher.send_to_viewer('reload') @@ -1298,11 +1258,20 @@ def settings_page(): for field, default in DEFAULTS['viewer'].items(): context[field] = settings[field] - auth_backends = [{ - 'name': backend.id, - 'text': backend.name, - 'selected': 'selected' if settings['auth_backend'] == backend.id else ''} - for backend in settings.auth_backends_list] + auth_backends = [] + for backend in settings.auth_backends_list: + if backend.template: + html, ctx = backend.template + context.update(ctx) + else: + html = None + auth_backends.append({ + 'name': backend.id, + 'text': backend.name, + 'template': html, + 'selected': 'selected' if settings['auth_backend'] == backend.id else '' + }) + context.update({ 'user': settings['user'], 'need_current_password': bool(settings['auth_backend']), diff --git a/static/js/settings.coffee b/static/js/settings.coffee index be95de06a..c174db1fe 100644 --- a/static/js/settings.coffee +++ b/static/js/settings.coffee @@ -80,14 +80,13 @@ $().ready -> .error (e) -> document.location.reload() - $('#auth_backend').change (e) -> - if $('#auth_backend').val() != 'auth_basic' - $('#user_group, #password_group, #password2_group').hide() - $('input:text[name="user"]').val('') - $('input:password[name="password"]').val('') - $('input:password[name="password2"]').val('') - else - $('#user_group, #password_group, #password2_group, #curpassword_group').show() + toggle_chunk = () -> + $("[id^=auth_chunk]").hide() + $.each $('#auth_backend option'), (e, t) -> + console.log t.value + $('#auth_backend-'+t.value).toggle $('#auth_backend').val() == t.value - $('#user_group, #password_group, #password2_group').toggle $('#auth_backend').val() == 'auth_basic' + $('#auth_backend').change (e) -> + toggle_chunk() + toggle_chunk() diff --git a/static/js/settings.js b/static/js/settings.js index 8929cd4d2..a66b4c945 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,6 +1,7 @@ // Generated by CoffeeScript 2.4.1 (function() { $().ready(function() { + var toggle_chunk; $('#request-error .close').click(function(e) { return $('#request-error .alert').hide(); }); @@ -90,17 +91,17 @@ return document.location.reload(); }); }); + toggle_chunk = function() { + $("[id^=auth_chunk]").hide(); + return $.each($('#auth_backend option'), function(e, t) { + console.log(t.value); + return $('#auth_backend-' + t.value).toggle($('#auth_backend').val() === t.value); + }); + }; $('#auth_backend').change(function(e) { - if ($('#auth_backend').val() !== 'auth_basic') { - $('#user_group, #password_group, #password2_group').hide(); - $('input:text[name="user"]').val(''); - $('input:password[name="password"]').val(''); - return $('input:password[name="password2"]').val(''); - } else { - return $('#user_group, #password_group, #password2_group, #curpassword_group').show(); - } + return toggle_chunk(); }); - return $('#user_group, #password_group, #password2_group').toggle($('#auth_backend').val() === 'auth_basic'); + return toggle_chunk(); }); }).call(this); diff --git a/templates/auth_basic.html b/templates/auth_basic.html new file mode 100644 index 000000000..7422e7be9 --- /dev/null +++ b/templates/auth_basic.html @@ -0,0 +1,18 @@ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
\ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html index 013df32b5..9e370842b 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -183,32 +183,22 @@

-
- -
- -
-
{% if context.need_current_password %} -
- -
- -
-
- {% endif %} -
- +
+
- +
-
- -
- -
+ {% endif %} + + {% for backend in context.auth_backends %} + {% if backend.template %} +
+ {% include backend.template %}
+ {% endif %} + {% endfor %}
From 62109ffe4eec0f786397be3af21dbbbffff34a50 Mon Sep 17 00:00:00 2001 From: Artem Martynovich Date: Fri, 14 Jun 2019 19:42:49 +0600 Subject: [PATCH 12/12] Fix auth backend switching error. --- server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.py b/server.py index 94bf86b86..5d2a40914 100755 --- a/server.py +++ b/server.py @@ -1230,8 +1230,8 @@ def settings_page(): if not settings.auth.check_password(current_pass): raise ValueError("Incorrect current password.") + settings.auth_backends[auth_backend].update_settings(current_pass) settings['auth_backend'] = auth_backend - settings.auth.update_settings(current_pass) for field, default in CONFIGURABLE_SETTINGS.items(): value = request.form.get(field, default)