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 role="separator" class="divider"></li>
% 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>
% endif
</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 class="modal-dialog" role="document">
<div class="modal-content">
<form action="${http_root}auth/login" method="post">
<form id="login-form">
<div class="modal-header">
<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>
@ -190,7 +190,8 @@ ${next.modalIncludes()}
</div>
</div>
<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>
<input type="hidden" id="admin_login" name="admin_login" value="1" />
</form>
@ -386,6 +387,29 @@ ${next.modalIncludes()}
$('#admin-login-modal').on('shown.bs.modal', function () {
$('#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
</script>
${next.javascriptIncludes()}

View file

@ -41,17 +41,15 @@
</div>
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<form action="${http_root}auth/login" method="post">
% if msg:
<div class="alert alert-danger" style="text-align: center; padding: 8px;">
${msg}
<form id="login-form">
<div id="incorrect-login" class="alert alert-danger" style="text-align: center; padding: 8px; display: none;">
Incorrect username or password.
</div>
% endif
<div class="form-group">
<label for="username" class="control-label">
Username
</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 class="form-group">
<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
</label>
</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>
</form>
</div>
@ -75,5 +73,30 @@
</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>
</html>

View file

@ -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()

View file

@ -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']

View file

@ -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():
"""

View file

@ -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 <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
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")
cherrypy.response.status = 401
return error_message

View file

@ -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',