diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 50515527..5be70a87 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -138,7 +138,7 @@
  • Admin Login
  • % endif - % if _session['expiry']: + % if _session['exp']:
  • Sign Out
  • % endif @@ -161,7 +161,7 @@ ${next.modalIncludes()} + + \ No newline at end of file diff --git a/plexpy/__init__.py b/plexpy/__init__.py index 9487f228..b093148c 100644 --- a/plexpy/__init__.py +++ b/plexpy/__init__.py @@ -175,17 +175,21 @@ def initialize(config_file): # Check if Tautulli has a uuid if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID: logger.debug(u"Generating UUID...") - my_uuid = generate_uuid() - CONFIG.__setattr__('PMS_UUID', my_uuid) + CONFIG.PMS_UUID = generate_uuid() CONFIG.write() - + # Check if Tautulli has an API key if CONFIG.API_KEY == '': logger.debug(u"Generating API key...") - api_key = generate_uuid() - CONFIG.__setattr__('API_KEY', api_key) + CONFIG.API_KEY = generate_uuid() CONFIG.write() - + + # Check if Tautulli has a jwt_secret + if CONFIG.JWT_SECRET == '' or not CONFIG.JWT_SECRET: + logger.debug(u"Generating JWT secret...") + CONFIG.JWT_SECRET = generate_uuid() + CONFIG.write() + # Get the currently installed version. Returns None, 'win32' or the git # hash. CURRENT_VERSION, CONFIG.GIT_REMOTE, CONFIG.GIT_BRANCH = versioncheck.getVersion() diff --git a/plexpy/config.py b/plexpy/config.py index 02db43a9..7bd326a7 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -611,7 +611,8 @@ _CONFIG_DEFINITIONS = { 'XBMC_ON_INTUP': (int, 'XBMC', 0), 'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0), 'XBMC_ON_CONCURRENT': (int, 'XBMC', 0), - 'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0) + 'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0), + 'JWT_SECRET': (str, 'Advanced', ''), } _BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK'] diff --git a/plexpy/session.py b/plexpy/session.py index 36bbab43..360125b6 100644 --- a/plexpy/session.py +++ b/plexpy/session.py @@ -23,16 +23,15 @@ def get_session_info(): """ Returns the session info for the user session """ - from plexpy.webauth import SESSION_KEY - _session = {'user_id': None, 'user': None, 'user_group': 'admin', - 'expiry': None} - try: - return cherrypy.session.get(SESSION_KEY, _session) - except AttributeError as e: - return _session + 'exp': None} + + if isinstance(cherrypy.request.login, dict): + return cherrypy.request.login + + return _session def get_session_user(): """ diff --git a/plexpy/webauth.py b/plexpy/webauth.py index e1c4617e..8819c935 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -18,12 +18,12 @@ # Form based authentication for CherryPy. Requires the # Session tool to be loaded. -from cgi import escape from datetime import datetime, timedelta import re import cherrypy from hashing_passwords import check_hash +import jwt import plexpy import logger @@ -32,7 +32,9 @@ from plexpy.users import Users, refresh_users from plexpy.plextv import PlexTV -SESSION_KEY = '_cp_username' +JWT_ALGORITHM = 'HS256' +JWT_COOKIE_NAME = 'tautulli_token_' + def user_login(username=None, password=None): if not username or not password: @@ -89,38 +91,58 @@ def user_login(username=None, password=None): return None + def check_credentials(username, password, admin_login='0'): """Verifies credentials for username and password. Returns True and the user group on success or False and no user group""" if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ - username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): + username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): return True, u'admin' elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ - username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: + username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: return True, u'admin' elif not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password): return True, u'guest' else: return False, None - + + +def check_jwt_token(): + jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID + jwt_token = cherrypy.request.cookie.get(jwt_cookie) + + if jwt_token: + try: + payload = jwt.decode( + jwt_token.value, plexpy.CONFIG.JWT_SECRET, leeway=timedelta(seconds=10), algorithms=[JWT_ALGORITHM] + ) + except (jwt.DecodeError, jwt.ExpiredSignatureError): + return None + + return payload + + def check_auth(*args, **kwargs): """A tool that looks in config for 'auth.require'. If found and it is not None, a login is required and the entry is evaluated as a list of conditions that the user must fulfill""" conditions = cherrypy.request.config.get('auth.require', None) if conditions is not None: - _session = cherrypy.session.get(SESSION_KEY) + payload = check_jwt_token() + + if payload: + cherrypy.request.login = payload - if _session and (_session['user'] and _session['expiry']) and _session['expiry'] > datetime.now(): - cherrypy.request.login = _session['user'] for condition in conditions: # A condition is just a callable that returns true or false if not condition(): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + else: raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout") - + + def requireAuth(*conditions): """A decorator that appends conditions to the auth.require config variable.""" @@ -144,11 +166,13 @@ def requireAuth(*conditions): def member_of(groupname): def check(): # replace with actual check if is in - return cherrypy.request.login == plexpy.CONFIG.HTTP_USERNAME and groupname == 'admin' + return cherrypy.request.login['user'] == plexpy.CONFIG.HTTP_USERNAME and groupname == 'admin' return check + def name_is(reqd_username): - return lambda: reqd_username == cherrypy.request.login + return lambda: reqd_username == cherrypy.request.login['user'] + # These might be handy @@ -161,6 +185,7 @@ def any_of(*conditions): return False return check + # By default all conditions are required, but this might still be # needed if you want to use it inside of an any_of(...) condition def all_of(*conditions): @@ -176,7 +201,12 @@ def all_of(*conditions): # Controller to provide login and logout actions class AuthController(object): - + + def check_auth_enabled(self): + if not plexpy.CONFIG.HTTP_BASIC_AUTH and plexpy.CONFIG.HTTP_PASSWORD: + return + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + def on_login(self, user_id, username, user_group): """Called on successful login""" @@ -197,7 +227,7 @@ class AuthController(object): def on_logout(self, username, user_group): """Called on logout""" - logger.debug(u"Tautulli WebAuth :: %s User '%s' logged out of Tautulli." % (user_group.capitalize(), username)) + logger.debug(u"Tautulli WebAuth :: %s user '%s' logged out of Tautulli." % (user_group.capitalize(), username)) def on_login_failed(self, username): """Called on failed login""" @@ -213,25 +243,48 @@ class AuthController(object): user_agent=user_agent, success=0) - def get_loginform(self, username="", msg=""): + def get_loginform(self): from plexpy.webserve import serve_template - return serve_template(templatename="login.html", title="Login", username=escape(username, True), msg=msg) + return serve_template(templatename="login.html", title="Login") @cherrypy.expose def index(self): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") @cherrypy.expose - def login(self, username=None, password=None, remember_me='0', admin_login='0'): - if not cherrypy.config.get('tools.sessions.on'): - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + def login(self): + self.check_auth_enabled() - if not username and not password: - return self.get_loginform() - - (vaild_login, user_group) = check_credentials(username, password, admin_login) + return self.get_loginform() - if vaild_login: + @cherrypy.expose + def logout(self): + self.check_auth_enabled() + + payload = check_jwt_token() + if payload: + self.on_logout(payload['user'], payload['user_group']) + + jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID + cherrypy.response.cookie[jwt_cookie] = 'expire' + cherrypy.response.cookie[jwt_cookie]['expires'] = 0 + cherrypy.response.cookie[jwt_cookie]['path'] = '/' + + cherrypy.request.login = None + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") + + @cherrypy.expose + @cherrypy.tools.json_out() + def signin(self, username=None, password=None, remember_me='0', admin_login='0'): + if cherrypy.request.method != 'POST': + cherrypy.response.status = 405 + return {'status': 'error', 'message': 'Sign in using POST.'} + + error_message = {'status': 'error', 'message': 'Incorrect username or password.'} + + valid_login, user_group = check_credentials(username, password, admin_login) + + if valid_login: if user_group == 'guest': if re.match(r"[^@]+@[^@]+\.[^@]+", username): user_details = Users().get_details(email=username) @@ -242,35 +295,37 @@ class AuthController(object): else: user_id = None - expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)) + time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60) + expiry = datetime.utcnow() + time_delta - cherrypy.request.login = username - cherrypy.session[SESSION_KEY] = {'user_id': user_id, - 'user': username, - 'user_group': user_group, - 'expiry': expiry} + payload = { + 'user_id': user_id, + 'user': username, + 'user_group': user_group, + 'exp': expiry + } + + jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM) self.on_login(user_id, username, user_group) - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + + jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID + cherrypy.response.cookie[jwt_cookie] = jwt_token + cherrypy.response.cookie[jwt_cookie]['expires'] = int(time_delta.total_seconds()) + cherrypy.response.cookie[jwt_cookie]['path'] = '/' + + cherrypy.request.login = payload + cherrypy.response.status = 200 + return {'status': 'success', 'token': jwt_token.decode('utf-8'), 'uuid': plexpy.CONFIG.PMS_UUID} elif admin_login == '1': self.on_login_failed(username) logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username) - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + cherrypy.response.status = 401 + return error_message + else: self.on_login_failed(username) logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username) - return self.get_loginform(username, u"Incorrect username/email or password.") - - @cherrypy.expose - def logout(self): - if not cherrypy.config.get('tools.sessions.on'): - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) - - _session = cherrypy.session.get(SESSION_KEY) - cherrypy.session[SESSION_KEY] = None - - if _session and _session['user']: - cherrypy.request.login = None - self.on_logout(_session['user'], _session['user_group']) - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") \ No newline at end of file + cherrypy.response.status = 401 + return error_message diff --git a/plexpy/webstart.py b/plexpy/webstart.py index 066e8863..87276b7e 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -35,7 +35,8 @@ def initialize(options): if enable_https: # If either the HTTPS certificate or key do not exist, try to make self-signed ones. if plexpy.CONFIG.HTTPS_CREATE_CERT and \ - (not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key))): + (not (https_cert and os.path.exists(https_cert)) or + not (https_key and os.path.exists(https_key))): if not create_https_certificates(https_cert, https_key): logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS") enable_https = False @@ -67,16 +68,17 @@ def initialize(options): protocol = "http" if options['http_password']: - logger.info(u"Tautulli WebStart :: Web server authentication is enabled, username is '%s'", options['http_username']) + logger.info(u"Tautulli WebStart :: Web server authentication is enabled, username is '%s'", + options['http_username']) if options['http_basic_auth']: - session_enabled = auth_enabled = False + auth_enabled = False basic_auth_enabled = True else: - options_dict['tools.sessions.on'] = session_enabled = auth_enabled = True + auth_enabled = True basic_auth_enabled = False cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth) else: - session_enabled = auth_enabled = basic_auth_enabled = False + auth_enabled = basic_auth_enabled = False if options['http_root'].strip('/'): plexpy.HTTP_ROOT = options['http_root'] = '/' + options['http_root'].strip('/') + '/' @@ -93,11 +95,6 @@ def initialize(options): 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css', 'text/javascript', 'application/json', 'application/javascript'], - 'tools.sessions.on': session_enabled, - 'tools.session.name': 'tautulli_session_id-' + plexpy.CONFIG.PMS_UUID, - 'tools.sessions.storage_type': 'file', - 'tools.sessions.storage_path': plexpy.CONFIG.CACHE_DIR, - 'tools.sessions.timeout': 30 * 24 * 60, # 30 days 'tools.auth.on': auth_enabled, 'tools.auth_basic.on': basic_auth_enabled, 'tools.auth_basic.realm': 'Tautulli web server',