mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-10 23:42:37 -07:00
Implement JWT instead of using cherrypy sessions
This commit is contained in:
parent
7c4c7bfc90
commit
a3e6e76158
7 changed files with 182 additions and 79 deletions
|
@ -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> 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> 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> 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> Sign In');
|
||||
}
|
||||
});
|
||||
});
|
||||
% endif
|
||||
</script>
|
||||
${next.javascriptIncludes()}
|
||||
|
|
|
@ -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> Sign In</button>
|
||||
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i> 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> 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> Sign In');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -175,15 +175,19 @@ 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
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -23,15 +23,14 @@ 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:
|
||||
'exp': None}
|
||||
|
||||
if isinstance(cherrypy.request.login, dict):
|
||||
return cherrypy.request.login
|
||||
|
||||
return _session
|
||||
|
||||
def get_session_user():
|
||||
|
|
|
@ -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,6 +91,7 @@ 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"""
|
||||
|
@ -104,23 +107,42 @@ def check_credentials(username, password, admin_login='0'):
|
|||
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):
|
||||
|
@ -177,6 +202,11 @@ def all_of(*conditions):
|
|||
|
||||
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)
|
||||
@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 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,
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'user': username,
|
||||
'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)
|
||||
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
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue