diff --git a/.gitignore b/.gitignore index 0eaec38e..bdef35a2 100644 --- a/.gitignore +++ b/.gitignore @@ -163,7 +163,8 @@ cython_debug/ Contents/Info.plist # Remove plexhints files -plexhints-temp +plexhints/ +plexhints-temp/ *cache.sqlite # Remove python modules diff --git a/Contents/Code/default_prefs.py b/Contents/Code/default_prefs.py index abc561ac..810c4f8f 100644 --- a/Contents/Code/default_prefs.py +++ b/Contents/Code/default_prefs.py @@ -23,6 +23,7 @@ enum_webapp_locale='en', str_webapp_http_host='0.0.0.0', int_webapp_http_port='9494', + bool_webapp_require_login='False', bool_webapp_log_werkzeug_messages='False', bool_migrate_locked_themes='False', bool_migrate_locked_collection_fields='False', diff --git a/Contents/Code/plex_api_helper.py b/Contents/Code/plex_api_helper.py index 640a6399..86194efd 100644 --- a/Contents/Code/plex_api_helper.py +++ b/Contents/Code/plex_api_helper.py @@ -25,6 +25,7 @@ from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest import plexapi.server +from plexapi.myplex import MyPlexAccount # must be imported after plexapi.server otherwise it breaks from plexapi.utils import reverseSearchType # local imports @@ -597,6 +598,58 @@ def get_plex_item(rating_key): return item +def get_user_info(token): + # type: (str) -> Optional[MyPlexAccount] + """ + Get the Plex user info. + + Parameters + ---------- + token : str + The Plex token. + + Returns + ------- + Optional[MyPlexAccount] + The Plex user info. + + Examples + -------- + >>> get_user_info(token='...') + ... + """ + try: + return MyPlexAccount(token=token) + except Exception as e: + Log.Error('Error getting user info: {}'.format(e)) + return None + + +def is_server_owner(user): + # type: (MyPlexAccount) -> bool + """ + Check if the user is the owner of the Plex server. + + Parameters + ---------- + user : MyPlexAccount + The Plex user info. + + Returns + ------- + py:class:`bool` + True if the user is the owner of the Plex server, False otherwise. + + Examples + -------- + >>> is_server_owner(user=...) + ... + """ + plex = setup_plexapi() + + return plex.account().username in {user.email, user.username} + + def process_queue(): # type: () -> None """ diff --git a/Contents/Code/webapp.py b/Contents/Code/webapp.py index 7e5b2b46..4bcfea25 100644 --- a/Contents/Code/webapp.py +++ b/Contents/Code/webapp.py @@ -8,6 +8,8 @@ import logging import os from threading import Lock, Thread +from typing import Optional +import uuid # plex debugging try: @@ -23,7 +25,7 @@ # lib imports import flask -from flask import Flask, Response, render_template, send_from_directory +from flask import Flask, Response, render_template as flask_render_template, send_from_directory, session from flask_babel import Babel import polib from six.moves.urllib.parse import quote_plus @@ -32,10 +34,39 @@ # local imports from constants import contributes_to, issue_urls, plugin_directory, plugin_identifier, themerr_data_directory import general_helper -from plex_api_helper import get_database_info, setup_plexapi +from plex_api_helper import get_database_info, get_user_info, is_server_owner, setup_plexapi import themerr_db_helper import tmdb_helper + +def render_template(*args, **kwargs): + # type: (str, str) -> flask.render_template + """ + Render a template. + + This function is a wrapper for flask.render_template that adds various useful globals to the template context. + + Parameters + ---------- + *args : str + The template name. + **kwargs : str + The template context. + + Returns + ------- + flask.render_template + The rendered template. + + Examples + -------- + >>> render_template('home.html', title='Home', items=items) + """ + kwargs['Prefs'] = Prefs + kwargs['is_logged_in'] = is_logged_in() + return flask_render_template(*args, **kwargs) + + # setup flask app app = Flask( import_name=__name__, @@ -107,6 +138,32 @@ database_cache_lock = Lock() +def create_secret(): + """ + Create secret file with random uuid. + + Examples + -------- + >>> create_secret() + """ + secret_file = os.path.join(themerr_data_directory, 'secret.json') + try: + with open(secret_file, 'r') as f: + app.secret_key = json.load(f)['secret'] + except Exception: + # create random secret + Log.Info('Creating random secret') + app.secret_key = uuid.uuid4().hex + try: + with open(secret_file, 'w') as f: + json.dump({'secret': app.secret_key}, f) + except Exception as e: + Log.Error('Error saving secret: {}'.format(e)) + + +create_secret() + + responses = { 500: Response(response='Internal Server Error', status=500, mimetype='text/plain') } @@ -495,3 +552,107 @@ def translations(): return Response(response=json.dumps(data), status=200, mimetype='application/json') + + +def is_logged_in(): + # type: () -> bool + """ + Check if the user is logged in. + + Returns + ------- + py:class:`bool` + True if the user is logged in, otherwise False. + + Examples + -------- + >>> is_logged_in() + """ + if not Prefs['bool_webapp_require_login']: + return True + + if "token" not in session: + return False + + token = session["token"] + user = get_user_info(token=token) + logged_in = user and is_server_owner(user=user) + return logged_in + + +@app.route("/logout", methods=["GET"]) +def logout(): + # type: () -> Response + """ + Logout the user. + + Returns + ------- + Response + The logout response. + + Examples + -------- + >>> logout() + """ + session.pop("token", None) + return flask.redirect(flask.url_for('home')) + + +@app.before_request +def check_login_status(): + # type: () -> Optional[flask.redirect] + """ + Check if the user is logged in. + + If the user is not logged in, redirect to the login page. + + Examples + -------- + >>> check_login_status() + """ + if not Prefs['bool_webapp_require_login']: + return + + if flask.request.path.startswith('/web'): + return + + if flask.request.path in {'/login', '/logout'}: + return + + if not is_logged_in(): + # not logged in, redirect to login page + return flask.redirect(flask.url_for('login', redirect_uri=flask.request.path)) + + +@app.route("/login", methods=["GET"]) +def login(redirect_uri="/"): + # type: (str) -> render_template + """ + Serve the login page. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + - `/login` + + Examples + -------- + >>> login() + """ + return render_template('login.html', title='Login', redirect_uri=redirect_uri) + + +@app.route("/login", methods=["POST"]) +def login_post(): + session.permanent = True + session["token"] = flask.request.form["token"] + if not is_logged_in(): + return flask.Response(status=401) + return flask.Response(status=200) diff --git a/Contents/DefaultPrefs.json b/Contents/DefaultPrefs.json index 91203691..4d6da48f 100644 --- a/Contents/DefaultPrefs.json +++ b/Contents/DefaultPrefs.json @@ -167,6 +167,14 @@ "default": "9494", "secure": "false" }, + { + "id": "bool_webapp_require_login", + "type": "bool", + "label": "Require login to access Web UI", + "default": "True", + "secure": "false", + "hidden": "true" + }, { "id": "bool_webapp_log_werkzeug_messages", "type": "bool", diff --git a/Contents/Resources/web/js/plexauth.js b/Contents/Resources/web/js/plexauth.js new file mode 100644 index 00000000..992721b9 --- /dev/null +++ b/Contents/Resources/web/js/plexauth.js @@ -0,0 +1,221 @@ +// Reference: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/data/interfaces/default/js/script.js + +var p = { + name: 'Unknown', + version: 'Unknown', + os: 'Unknown' +}; +if (typeof platform !== 'undefined') { + p.name = platform.name; + p.version = platform.version; + p.os = platform.os.toString(); +} + +function getPlexHeaders(clientID) { + return { + 'Accept': 'application/json', + 'X-Plex-Product': 'Themerr', + 'X-Plex-Version': 'Plex OAuth', + 'X-Plex-Client-Identifier': clientID ? clientID : getLocalStorage('Themerr_ClientID', uuidv4(), false), + 'X-Plex-Platform': p.name, + 'X-Plex-Platform-Version': p.version, + 'X-Plex-Model': 'Plex OAuth', + 'X-Plex-Device': p.os, + 'X-Plex-Device-Name': p.name + ' (Themerr)', + 'X-Plex-Device-Screen-Resolution': window.screen.width + 'x' + window.screen.height, + 'X-Plex-Language': 'en' + }; +} + +function encodeData(data) { + return Object.keys(data).map(function (key) { + return [key, data[key]].map(encodeURIComponent).join("="); + }).join("&"); +} + +var plex_oauth_window = null; +const plex_oauth_loader = '' + + '
' + + '
' + + '' + + '
' + + 'Redirecting to the Plex login page...' + + '
' + + '
'; + +function closePlexOAuthWindow() { + if (plex_oauth_window) { + plex_oauth_window.close(); + } +} + +function getKeyPath(key, path) { + var key_path = key; + if (path !== false) { + key_path = `${key_path}_${window.location.pathname}`; + } + return key_path; +} + +function setLocalStorage(key, value, path) { + var key_path = getKeyPath(key, path); + localStorage.setItem(key_path, value); +} + +function getLocalStorage(key, default_value, path) { + var key_path = getKeyPath(key, path); + var value = localStorage.getItem(key_path); + if (value !== null) { + return value; + } else if (default_value !== undefined) { + setLocalStorage(key, default_value, path); + return default_value; + } +} + +function uuidv4() { + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) { + var cryptoObj = window.crypto || window.msCrypto; // for IE 11 + return (c ^ cryptoObj.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16); + }); +} + +getPlexOAuthPin = function (clientID) { + var x_plex_headers = getPlexHeaders(clientID); + var deferred = $.Deferred(); + + $.ajax({ + url: 'https://plex.tv/api/v2/pins?strong=true', + type: 'POST', + headers: x_plex_headers, + success: function (data) { + deferred.resolve({ pin: data.id, code: data.code }); + }, + error: function () { + closePlexOAuthWindow(); + deferred.reject(); + } + }); + return deferred; +}; + +function PopupCenter(url, title, w, h) { + // Fixes dual-screen position Most browsers Firefox + var dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX; + var dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY; + + var width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width; + var height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height; + + var left = ((width / 2) - (w / 2)) + dualScreenLeft; + var top = ((height / 2) - (h / 2)) + dualScreenTop; + var newWindow = window.open(url, title, `scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`); + + // Puts focus on the newWindow + if (window.focus) { + newWindow.focus(); + } + + return newWindow; +} + +var polling = null; + +function PlexOAuth(success, error, pre, clientID) { + if (typeof pre === "function") { + pre(); + } + closePlexOAuthWindow(); + plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700); + $(plex_oauth_window.document.body).html(plex_oauth_loader); + + getPlexOAuthPin(clientID).then(function (data) { + var x_plex_headers = getPlexHeaders(clientID); + const pin = data.pin; + const code = data.code; + + var oauth_params = { + 'clientID': x_plex_headers['X-Plex-Client-Identifier'], + 'context[device][product]': x_plex_headers['X-Plex-Product'], + 'context[device][version]': x_plex_headers['X-Plex-Version'], + 'context[device][platform]': x_plex_headers['X-Plex-Platform'], + 'context[device][platformVersion]': x_plex_headers['X-Plex-Platform-Version'], + 'context[device][device]': x_plex_headers['X-Plex-Device'], + 'context[device][deviceName]': x_plex_headers['X-Plex-Device-Name'], + 'context[device][model]': x_plex_headers['X-Plex-Model'], + 'context[device][screenResolution]': x_plex_headers['X-Plex-Device-Screen-Resolution'], + 'context[device][layout]': 'desktop', + 'code': code + }; + + plex_oauth_window.location = `https://app.plex.tv/auth/#!?${encodeData(oauth_params)}`; + polling = pin; + + (function poll() { + $.ajax({ + url: `https://plex.tv/api/v2/pins/${pin}`, + type: 'GET', + headers: x_plex_headers, + success: function (data) { + if (data.authToken) { + closePlexOAuthWindow(); + if (typeof success === "function") { + success(data.authToken); + } + } + }, + error: function (jqXHR, textStatus, errorThrown) { + if (textStatus !== "timeout") { + closePlexOAuthWindow(); + if (typeof error === "function") { + error(); + } + } + }, + complete: function () { + if (!plex_oauth_window.closed && polling === pin) { + setTimeout(function () { poll(); }, 1000); + } + }, + timeout: 10000 + }); + })(); + }, function () { + closePlexOAuthWindow(); + if (typeof error === "function") { + error(); + } + }); +} diff --git a/Contents/Resources/web/templates/login.html b/Contents/Resources/web/templates/login.html new file mode 100644 index 00000000..357287e2 --- /dev/null +++ b/Contents/Resources/web/templates/login.html @@ -0,0 +1,75 @@ +{# Reference: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/data/interfaces/default/login.html #} + +{% extends 'base.html' %} +{% block modals %} +{% endblock modals %} + +{% block content %} +
+
+ +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} \ No newline at end of file diff --git a/Contents/Resources/web/templates/navbar.html b/Contents/Resources/web/templates/navbar.html index d29ef6a9..5a8f57ea 100644 --- a/Contents/Resources/web/templates/navbar.html +++ b/Contents/Resources/web/templates/navbar.html @@ -58,6 +58,13 @@ href="/"> {{ _('Home') }} + + {% if Prefs['bool_webapp_require_login'] and is_logged_in() %} +
  • + {{ _('Logout') }} +
  • + {% endif %} diff --git a/docs/source/about/usage.rst b/docs/source/about/usage.rst index 54e756ce..9d7262e6 100644 --- a/docs/source/about/usage.rst +++ b/docs/source/about/usage.rst @@ -298,6 +298,24 @@ Description Default ``9494`` +Web UI Require Login +^^^^^^^^^^^^^^^^^^^^ + +Description + If set to ``True``, the Web UI will require the user to log in with their Plex account before accessing the Web UI. + Only the owner of the Plex Media Server will be able to log in. + +.. danger:: + Disabling this value will allow anyone with network access to the Web UI to access it. + + For security reasons, this value is not configurable via the Web UI. You need to manually edit the plug-in's XML + preferences file. If you do not know how to do this, you should not disable this value. + + Changing this value requires a Plex Media Server restart. + +Default + ``True`` + Log all web server messages ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/requirements-dev.txt b/requirements-dev.txt index 126d00f5..db5ce39c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ # development environment requirements, these should not be distributed flake8==3.9.2;python_version<"3" +mock==3.0.5;python_version<"3" m2r2==0.3.2;python_version<"3" numpydoc==0.9.2;python_version<"3" plexhints==2023.1211.160853 # type hinting library for plex development diff --git a/tests/conftest.py b/tests/conftest.py index 6bd28a15..d2e7b982 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,15 +5,21 @@ import os import sys import time +from mock import patch # lib imports import plexapi from plexapi.exceptions import NotFound from plexapi.server import PlexServer from plexhints.agent_kit import Agent +from plexhints.prefs_kit import _PreferenceSet import pytest import requests +# create a fake directory for plexhints +if not os.path.exists('plexhints'): + os.mkdir('plexhints') + # add Contents directory to the system path pytest.root_dir = root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) pytest.contents_dir = contents_dir = os.path.join(root_dir, 'Contents') @@ -87,6 +93,18 @@ def agent(request): return Themerr() +# extend the _PreferenceSet class, and give it a setter +class PreferenceSet(_PreferenceSet): + def __setitem__(self, key, value): + self.update_user_values(**{key: value}) + + +@pytest.fixture(scope='function') +def prefs_webapp_patch(): + with patch('Code.webapp.Prefs', new_callable=lambda: PreferenceSet('test_webapp_prefs')) as mock_prefs: + yield mock_prefs + + @pytest.fixture(scope='function') def test_client(): """Create a test client for testing webapp endpoints""" @@ -102,6 +120,13 @@ def test_client(): yield test_client # this is where the testing happens! +@pytest.fixture(scope='function') +def test_client_login_disabled(test_client, prefs_webapp_patch): + """Create a test client for testing webapp endpoints with login disabled""" + webapp.Prefs['bool_webapp_require_login'] = False # disable login requirement + yield test_client + + # plex server fixtures @pytest.fixture(scope="session") def plugin_logs(): @@ -111,7 +136,6 @@ def plugin_logs(): yield plugin_logs -# plex server fixtures @pytest.fixture(scope="session") def plugin_log_file(): # the primary plugin log file @@ -136,6 +160,11 @@ def plex(request, sess): return PlexServer(SERVER_BASEURL, SERVER_TOKEN, session=sess) +@pytest.fixture(scope="session") +def plex_token(): + return SERVER_TOKEN + + @pytest.fixture(params=MOVIE_SECTIONS, scope="session") def movie_section_names(plex, request): library_section = request.param diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py index 03087201..2fca653e 100644 --- a/tests/functional/test_webapp.py +++ b/tests/functional/test_webapp.py @@ -15,14 +15,16 @@ def remove_themerr_db_cache_file(): _backup_file_name = "{}.bak".format(webapp.database_cache_file) # rename the file, so it is not found - os.rename(webapp.database_cache_file, _backup_file_name) + if os.path.exists(webapp.database_cache_file): + os.rename(webapp.database_cache_file, _backup_file_name) yield # rename the file back - os.rename(_backup_file_name, webapp.database_cache_file) + if os.path.exists(_backup_file_name): + os.rename(_backup_file_name, webapp.database_cache_file) -def test_home(test_client): +def test_home_login_disabled(test_client_login_disabled): """ WHEN the '/' page is requested (GET) THEN check that the response is valid @@ -30,49 +32,98 @@ def test_home(test_client): Repeat for '/home' """ try: - response = test_client.get('/') + response = test_client_login_disabled.get('/') except AttributeError: pytest.skip("cannot access Plex token/server") else: assert response.status_code == 200 - response = test_client.get('/home') + response = test_client_login_disabled.get('/home') assert response.status_code == 200 assert 'id="section_' in response.data.decode('utf-8') -def test_home_without_cache(remove_themerr_db_cache_file, test_client): +def test_home(test_client): """ WHEN the '/' page is requested (GET) - THEN check that the response is valid + THEN check that the response is a redirect + + Repeat for '/home' """ try: response = test_client.get('/') except AttributeError: pytest.skip("cannot access Plex token/server") + else: + assert response.status_code == 302 + + response = test_client.get('/home') + assert response.status_code == 302 + + +def test_home_without_cache_login_disabled(remove_themerr_db_cache_file, test_client_login_disabled): + """ + WHEN the '/' page is requested (GET) + THEN check that the response is valid + """ + try: + response = test_client_login_disabled.get('/') + except AttributeError: + pytest.skip("cannot access Plex token/server") else: assert response.status_code == 200 assert 'Database is being cached' in response.data.decode('utf-8') -def test_image(test_client): +def test_home_without_cache(remove_themerr_db_cache_file, test_client): + """ + WHEN the '/' page is requested (GET) + THEN check that the response is a redirect + """ + try: + response = test_client.get('/') + except AttributeError: + pytest.skip("cannot access Plex token/server") + else: + assert response.status_code == 302 + + +def test_image_login_disabled(test_client_login_disabled): """ WHEN the '/favicon.ico' file is requested (GET) THEN check that the response is valid THEN check the content type is 'image/vnd.microsoft.icon' """ - response = test_client.get('favicon.ico') + response = test_client_login_disabled.get('favicon.ico') assert response.status_code == 200 assert response.content_type == 'image/vnd.microsoft.icon' -def test_status(test_client): +def test_image(test_client): + """ + WHEN the '/favicon.ico' file is requested (GET) + THEN check that the response is a redirect + """ + response = test_client.get('favicon.ico') + assert response.status_code == 302 + + +def test_status_no_login(test_client_login_disabled): """ WHEN the '/status' page is requested (GET) THEN check that the response is valid """ - response = test_client.get('/status') + response = test_client_login_disabled.get('/status') assert response.status_code == 200 assert response.content_type == 'application/json' + + +def test_status(test_client): + """ + WHEN the '/status' page is requested (GET) + THEN check that the response is a redirect + """ + response = test_client.get('/status') + assert response.status_code == 302 diff --git a/tests/unit/test_plex_api_helper.py b/tests/unit/test_plex_api_helper.py index f841e06a..79239258 100644 --- a/tests/unit/test_plex_api_helper.py +++ b/tests/unit/test_plex_api_helper.py @@ -24,3 +24,20 @@ def test_change_lock_status(section, lock): change_status = plex_api_helper.change_lock_status(item, field=field, lock=lock) assert change_status, 'change_lock_status did not return True' assert item.isLocked(field=field) == lock, 'Failed to change lock status to {}'.format(lock) + + +def test_get_user_info(plex_token): + assert plex_api_helper.get_user_info(token=plex_token) + + +def test_get_user_info_invalid_token(): + assert not plex_api_helper.get_user_info(token='invalid_token') + + +def test_is_server_owner(plex_token): + user = plex_api_helper.get_user_info(token=plex_token) + assert plex_api_helper.is_server_owner(user=user) + + +def test_is_not_server_owner(): + assert plex_api_helper.is_server_owner(user={}) is None