Implement JWT instead of using cherrypy sessions

This commit is contained in:
JonnyWong16 2018-01-08 22:25:52 -08:00
parent 7c4c7bfc90
commit a3e6e76158
7 changed files with 182 additions and 79 deletions

View file

@ -138,7 +138,7 @@
<li><a href="#" data-target="#admin-login-modal" data-toggle="modal"><i class="fa fa-fw fa-lock"></i> Admin Login</a></li> <li><a href="#" data-target="#admin-login-modal" data-toggle="modal"><i class="fa fa-fw fa-lock"></i> Admin Login</a></li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
% endif % endif
% if _session['expiry']: % if _session['exp']:
<li><a href="${http_root}auth/logout"><i class="fa fa-fw fa-sign-out"></i> Sign Out</a></li> <li><a href="${http_root}auth/logout"><i class="fa fa-fw fa-sign-out"></i> Sign Out</a></li>
% endif % endif
</ul> </ul>
@ -161,7 +161,7 @@ ${next.modalIncludes()}
<div id="admin-login-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="admin-login-modal"> <div id="admin-login-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="admin-login-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<form action="${http_root}auth/login" method="post"> <form id="login-form">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">Admin Login</h4> <h4 class="modal-title">Admin Login</h4>
@ -190,7 +190,8 @@ ${next.modalIncludes()}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button> <span id="incorrect-login" style="padding-right: 25px; display: none;">Incorrect username or password.</span>
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div> </div>
<input type="hidden" id="admin_login" name="admin_login" value="1" /> <input type="hidden" id="admin_login" name="admin_login" value="1" />
</form> </form>
@ -386,6 +387,29 @@ ${next.modalIncludes()}
$('#admin-login-modal').on('shown.bs.modal', function () { $('#admin-login-modal').on('shown.bs.modal', function () {
$('#admin-login-modal #username').focus() $('#admin-login-modal #username').focus()
}) })
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
% endif % endif
</script> </script>
${next.javascriptIncludes()} ${next.javascriptIncludes()}

View file

@ -41,17 +41,15 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-6 col-sm-offset-3"> <div class="col-sm-6 col-sm-offset-3">
<form action="${http_root}auth/login" method="post"> <form id="login-form">
% if msg: <div id="incorrect-login" class="alert alert-danger" style="text-align: center; padding: 8px; display: none;">
<div class="alert alert-danger" style="text-align: center; padding: 8px;"> Incorrect username or password.
${msg}
</div> </div>
% endif
<div class="form-group"> <div class="form-group">
<label for="username" class="control-label"> <label for="username" class="control-label">
Username Username
</label> </label>
<input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" value="${username}" autofocus> <input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" autofocus>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="control-label"> <label for="password" class="control-label">
@ -65,7 +63,7 @@
<input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me <input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me
</label> </label>
</div> </div>
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button> <button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div> </div>
</form> </form>
</div> </div>
@ -75,5 +73,30 @@
</div> </div>
</div> </div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script>
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
</script>
</body> </body>
</html> </html>

View file

@ -175,15 +175,19 @@ def initialize(config_file):
# Check if Tautulli has a uuid # Check if Tautulli has a uuid
if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID: if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID:
logger.debug(u"Generating UUID...") logger.debug(u"Generating UUID...")
my_uuid = generate_uuid() CONFIG.PMS_UUID = generate_uuid()
CONFIG.__setattr__('PMS_UUID', my_uuid)
CONFIG.write() CONFIG.write()
# Check if Tautulli has an API key # Check if Tautulli has an API key
if CONFIG.API_KEY == '': if CONFIG.API_KEY == '':
logger.debug(u"Generating API key...") logger.debug(u"Generating API key...")
api_key = generate_uuid() CONFIG.API_KEY = generate_uuid()
CONFIG.__setattr__('API_KEY', api_key) 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() CONFIG.write()
# Get the currently installed version. Returns None, 'win32' or the git # Get the currently installed version. Returns None, 'win32' or the git

View file

@ -611,7 +611,8 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_INTUP': (int, 'XBMC', 0), 'XBMC_ON_INTUP': (int, 'XBMC', 0),
'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0), 'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0),
'XBMC_ON_CONCURRENT': (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'] _BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']

View file

@ -23,15 +23,14 @@ def get_session_info():
""" """
Returns the session info for the user session Returns the session info for the user session
""" """
from plexpy.webauth import SESSION_KEY
_session = {'user_id': None, _session = {'user_id': None,
'user': None, 'user': None,
'user_group': 'admin', 'user_group': 'admin',
'expiry': None} 'exp': None}
try:
return cherrypy.session.get(SESSION_KEY, _session) if isinstance(cherrypy.request.login, dict):
except AttributeError as e: return cherrypy.request.login
return _session return _session
def get_session_user(): def get_session_user():

View file

@ -18,12 +18,12 @@
# Form based authentication for CherryPy. Requires the # Form based authentication for CherryPy. Requires the
# Session tool to be loaded. # Session tool to be loaded.
from cgi import escape
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re import re
import cherrypy import cherrypy
from hashing_passwords import check_hash from hashing_passwords import check_hash
import jwt
import plexpy import plexpy
import logger import logger
@ -32,7 +32,9 @@ from plexpy.users import Users, refresh_users
from plexpy.plextv import PlexTV from plexpy.plextv import PlexTV
SESSION_KEY = '_cp_username' JWT_ALGORITHM = 'HS256'
JWT_COOKIE_NAME = 'tautulli_token_'
def user_login(username=None, password=None): def user_login(username=None, password=None):
if not username or not password: if not username or not password:
@ -89,6 +91,7 @@ def user_login(username=None, password=None):
return None return None
def check_credentials(username, password, admin_login='0'): def check_credentials(username, password, admin_login='0'):
"""Verifies credentials for username and password. """Verifies credentials for username and password.
Returns True and the user group on success or False and no user group""" Returns True and the user group on success or False and no user group"""
@ -104,23 +107,42 @@ def check_credentials(username, password, admin_login='0'):
else: else:
return False, None 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): def check_auth(*args, **kwargs):
"""A tool that looks in config for 'auth.require'. If found and it """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 is not None, a login is required and the entry is evaluated as a list of
conditions that the user must fulfill""" conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', None) conditions = cherrypy.request.config.get('auth.require', None)
if conditions is not 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: for condition in conditions:
# A condition is just a callable that returns true or false # A condition is just a callable that returns true or false
if not condition(): if not condition():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else: else:
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout") raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout")
def requireAuth(*conditions): def requireAuth(*conditions):
"""A decorator that appends conditions to the auth.require config """A decorator that appends conditions to the auth.require config
variable.""" variable."""
@ -144,11 +166,13 @@ def requireAuth(*conditions):
def member_of(groupname): def member_of(groupname):
def check(): def check():
# replace with actual check if <username> is in <groupname> # replace with actual check if <username> is in <groupname>
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 return check
def name_is(reqd_username): def name_is(reqd_username):
return lambda: reqd_username == cherrypy.request.login return lambda: reqd_username == cherrypy.request.login['user']
# These might be handy # These might be handy
@ -161,6 +185,7 @@ def any_of(*conditions):
return False return False
return check return check
# By default all conditions are required, but this might still be # By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition # needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions): def all_of(*conditions):
@ -177,6 +202,11 @@ def all_of(*conditions):
class AuthController(object): 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): def on_login(self, user_id, username, user_group):
"""Called on successful login""" """Called on successful login"""
@ -197,7 +227,7 @@ class AuthController(object):
def on_logout(self, username, user_group): def on_logout(self, username, user_group):
"""Called on logout""" """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): def on_login_failed(self, username):
"""Called on failed login""" """Called on failed login"""
@ -213,25 +243,48 @@ class AuthController(object):
user_agent=user_agent, user_agent=user_agent,
success=0) success=0)
def get_loginform(self, username="", msg=""): def get_loginform(self):
from plexpy.webserve import serve_template 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 @cherrypy.expose
def index(self): def index(self):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose @cherrypy.expose
def login(self, username=None, password=None, remember_me='0', admin_login='0'): def login(self):
if not cherrypy.config.get('tools.sessions.on'): self.check_auth_enabled()
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
if not username and not password:
return self.get_loginform() return self.get_loginform()
(vaild_login, user_group) = check_credentials(username, password, admin_login) @cherrypy.expose
def logout(self):
self.check_auth_enabled()
if vaild_login: 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 user_group == 'guest':
if re.match(r"[^@]+@[^@]+\.[^@]+", username): if re.match(r"[^@]+@[^@]+\.[^@]+", username):
user_details = Users().get_details(email=username) user_details = Users().get_details(email=username)
@ -242,35 +295,37 @@ class AuthController(object):
else: else:
user_id = None 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 payload = {
cherrypy.session[SESSION_KEY] = {'user_id': user_id, 'user_id': user_id,
'user': username, 'user': username,
'user_group': user_group, 'user_group': user_group,
'expiry': expiry} 'exp': expiry
}
jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM)
self.on_login(user_id, username, user_group) 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': elif admin_login == '1':
self.on_login_failed(username) self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % 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: else:
self.on_login_failed(username) self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username) logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username)
return self.get_loginform(username, u"Incorrect username/email or password.") cherrypy.response.status = 401
return error_message
@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")

View file

@ -35,7 +35,8 @@ def initialize(options):
if enable_https: if enable_https:
# If either the HTTPS certificate or key do not exist, try to make self-signed ones. # If either the HTTPS certificate or key do not exist, try to make self-signed ones.
if plexpy.CONFIG.HTTPS_CREATE_CERT and \ 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): if not create_https_certificates(https_cert, https_key):
logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS") logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS")
enable_https = False enable_https = False
@ -67,16 +68,17 @@ def initialize(options):
protocol = "http" protocol = "http"
if options['http_password']: 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']: if options['http_basic_auth']:
session_enabled = auth_enabled = False auth_enabled = False
basic_auth_enabled = True basic_auth_enabled = True
else: else:
options_dict['tools.sessions.on'] = session_enabled = auth_enabled = True auth_enabled = True
basic_auth_enabled = False basic_auth_enabled = False
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth) cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth)
else: else:
session_enabled = auth_enabled = basic_auth_enabled = False auth_enabled = basic_auth_enabled = False
if options['http_root'].strip('/'): if options['http_root'].strip('/'):
plexpy.HTTP_ROOT = options['http_root'] = '/' + 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', 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css',
'text/javascript', 'application/json', 'text/javascript', 'application/json',
'application/javascript'], '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.on': auth_enabled,
'tools.auth_basic.on': basic_auth_enabled, 'tools.auth_basic.on': basic_auth_enabled,
'tools.auth_basic.realm': 'Tautulli web server', 'tools.auth_basic.realm': 'Tautulli web server',