1# -*- coding: utf-8 -*- 2 3# This file is part of Tautulli. 4# 5# Tautulli is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Tautulli is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Tautulli. If not, see <http://www.gnu.org/licenses/>. 17 18 19# https://github.com/cherrypy/tools/blob/master/AuthenticationAndAccessRestrictions 20# Form based authentication for CherryPy. Requires the 21# Session tool to be loaded. 22 23from future.builtins import object 24 25from datetime import datetime, timedelta 26from future.moves.urllib.parse import quote, unquote 27 28import cherrypy 29from hashing_passwords import check_hash 30import jwt 31 32import plexpy 33if plexpy.PYTHON2: 34 import logger 35 from database import MonitorDatabase 36 from helpers import timestamp 37 from users import Users, refresh_users 38 from plextv import PlexTV 39else: 40 from plexpy import logger 41 from plexpy.database import MonitorDatabase 42 from plexpy.helpers import timestamp 43 from plexpy.users import Users, refresh_users 44 from plexpy.plextv import PlexTV 45 46# Monkey patch SameSite support into cookies. 47# https://stackoverflow.com/a/50813092 48try: 49 from http.cookies import Morsel 50except ImportError: 51 from Cookie import Morsel 52Morsel._reserved[str('samesite')] = str('SameSite') 53 54JWT_ALGORITHM = 'HS256' 55JWT_COOKIE_NAME = 'tautulli_token_' 56 57 58def plex_user_login(token=None, headers=None): 59 user_token = None 60 user_id = None 61 62 # Try to login to Plex.tv to check if the user has a vaild account 63 if token: 64 plex_tv = PlexTV(token=token, headers=headers) 65 plex_user = plex_tv.get_plex_account_details() 66 if plex_user: 67 user_token = token 68 user_id = plex_user['user_id'] 69 else: 70 return None 71 72 if user_token and user_id: 73 # Try to retrieve the user from the database. 74 # Also make sure guest access is enabled for the user and the user is not deleted. 75 user_data = Users() 76 user_details = user_data.get_details(user_id=user_id) 77 if user_id != str(user_details['user_id']): 78 # The user is not in the database. 79 return None 80 elif plexpy.CONFIG.HTTP_PLEX_ADMIN and user_details['is_admin']: 81 # Plex admin login 82 return user_details, 'admin' 83 elif not user_details['allow_guest'] or user_details['deleted_user']: 84 # Guest access is disabled or the user is deleted. 85 return None 86 87 # Stop here if guest access is not enabled 88 if not plexpy.CONFIG.ALLOW_GUEST_ACCESS: 89 return None 90 91 # The user is in the database, and guest access is enabled, so try to retrieve a server token. 92 # If a server token is returned, then the user is a valid friend of the server. 93 plex_tv = PlexTV(token=user_token, headers=headers) 94 server_token = plex_tv.get_server_token() 95 if server_token: 96 97 # Register the new user / update the access tokens. 98 monitor_db = MonitorDatabase() 99 try: 100 logger.debug("Tautulli WebAuth :: Registering token for user '%s' in the database." 101 % user_details['username']) 102 result = monitor_db.action('UPDATE users SET server_token = ? WHERE user_id = ?', 103 [server_token, user_details['user_id']]) 104 105 if result: 106 # Refresh the users list to make sure we have all the correct permissions. 107 refresh_users() 108 # Successful login 109 return user_details, 'guest' 110 else: 111 logger.warn("Tautulli WebAuth :: Unable to register user '%s' in database." 112 % user_details['username']) 113 return None 114 except Exception as e: 115 logger.warn("Tautulli WebAuth :: Unable to register user '%s' in database: %s." 116 % (user_details['username'], e)) 117 return None 118 else: 119 logger.warn("Tautulli WebAuth :: Unable to retrieve Plex.tv server token for user '%s'." 120 % user_details['username']) 121 return None 122 123 elif token: 124 logger.warn("Tautulli WebAuth :: Unable to retrieve Plex.tv user token for Plex OAuth.") 125 return None 126 127 128def check_credentials(username=None, password=None, token=None, admin_login='0', headers=None): 129 """Verifies credentials for username and password. 130 Returns True and the user group on success or False and no user group""" 131 132 if username and password: 133 if plexpy.CONFIG.HTTP_PASSWORD: 134 user_details = {'user_id': None, 'username': username} 135 if username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): 136 return True, user_details, 'admin' 137 138 if plexpy.CONFIG.HTTP_PLEX_ADMIN or (not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS): 139 plex_login = plex_user_login(token=token, headers=headers) 140 if plex_login is not None: 141 return True, plex_login[0], plex_login[1] 142 143 return False, None, None 144 145 146def get_jwt_token(): 147 jwt_cookie = str(JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID) 148 jwt_token = cherrypy.request.cookie.get(jwt_cookie) 149 150 if jwt_token: 151 return jwt_token.value 152 153 154def check_jwt_token(): 155 jwt_token = get_jwt_token() 156 157 if jwt_token: 158 try: 159 payload = jwt.decode( 160 jwt_token, plexpy.CONFIG.JWT_SECRET, leeway=timedelta(seconds=10), algorithms=[JWT_ALGORITHM] 161 ) 162 except (jwt.DecodeError, jwt.ExpiredSignatureError): 163 return None 164 165 if not Users().get_user_login(jwt_token=jwt_token): 166 return None 167 168 return payload 169 170 171def check_auth(*args, **kwargs): 172 """A tool that looks in config for 'auth.require'. If found and it 173 is not None, a login is required and the entry is evaluated as a list of 174 conditions that the user must fulfill""" 175 conditions = cherrypy.request.config.get('auth.require', None) 176 if conditions is not None: 177 payload = check_jwt_token() 178 179 if payload: 180 cherrypy.request.login = payload 181 182 for condition in conditions: 183 # A condition is just a callable that returns true or false 184 if not condition(): 185 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) 186 187 else: 188 redirect_uri = cherrypy.request.wsgi_environ['REQUEST_URI'] 189 if redirect_uri: 190 redirect_uri = '?redirect_uri=' + quote(redirect_uri) 191 192 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout" + redirect_uri) 193 194 195def requireAuth(*conditions): 196 """A decorator that appends conditions to the auth.require config 197 variable.""" 198 def decorate(f): 199 if not hasattr(f, '_cp_config'): 200 f._cp_config = dict() 201 if 'auth.require' not in f._cp_config: 202 f._cp_config['auth.require'] = [] 203 f._cp_config['auth.require'].extend(conditions) 204 return f 205 return decorate 206 207 208# Conditions are callables that return True 209# if the user fulfills the conditions they define, False otherwise 210# 211# They can access the current username as cherrypy.request.login 212# 213# Define those at will however suits the application. 214 215def member_of(user_group): 216 return lambda: cherrypy.request.login and cherrypy.request.login['user_group'] == user_group 217 218 219def name_is(user_name): 220 return lambda: cherrypy.request.login and cherrypy.request.login['user'] == user_name 221 222 223# These might be handy 224 225def any_of(*conditions): 226 """Returns True if any of the conditions match""" 227 def check(): 228 for c in conditions: 229 if c(): 230 return True 231 return False 232 return check 233 234 235# By default all conditions are required, but this might still be 236# needed if you want to use it inside of an any_of(...) condition 237def all_of(*conditions): 238 """Returns True if all of the conditions match""" 239 def check(): 240 for c in conditions: 241 if not c(): 242 return False 243 return True 244 return check 245 246 247def check_rate_limit(ip_address): 248 monitor_db = MonitorDatabase() 249 result = monitor_db.select('SELECT timestamp, success FROM user_login ' 250 'WHERE ip_address = ? ' 251 'AND timestamp >= ( ' 252 'SELECT CASE WHEN MAX(timestamp) IS NULL THEN 0 ELSE MAX(timestamp) END ' 253 'FROM user_login WHERE ip_address = ? AND success = 1) ' 254 'ORDER BY timestamp DESC', 255 [ip_address, ip_address]) 256 257 try: 258 last_timestamp = result[0]['timestamp'] 259 except IndexError: 260 last_timestamp = 0 261 262 try: 263 last_success = max(login['timestamp'] for login in result if login['success']) 264 except ValueError: 265 last_success = 0 266 267 max_timestamp = max(last_success, last_timestamp - plexpy.CONFIG.HTTP_RATE_LIMIT_ATTEMPTS_INTERVAL) 268 attempts = [login for login in result if login['timestamp'] >= max_timestamp and not login['success']] 269 270 if len(attempts) >= plexpy.CONFIG.HTTP_RATE_LIMIT_ATTEMPTS: 271 return max(last_timestamp - (timestamp() - plexpy.CONFIG.HTTP_RATE_LIMIT_LOCKOUT_TIME), 0) 272 273 274# Controller to provide login and logout actions 275 276class AuthController(object): 277 278 def check_auth_enabled(self): 279 if not plexpy.CONFIG.HTTP_BASIC_AUTH and plexpy.CONFIG.HTTP_PASSWORD: 280 return 281 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) 282 283 def on_login(self, username=None, user_id=None, user_group=None, success=False, oauth=False, 284 expiry=None, jwt_token=None): 285 """Called on successful login""" 286 287 # Save login to the database 288 ip_address = cherrypy.request.remote.ip 289 host = cherrypy.request.base 290 user_agent = cherrypy.request.headers.get('User-Agent') 291 292 Users().set_user_login(user_id=user_id, 293 user=username, 294 user_group=user_group, 295 ip_address=ip_address, 296 host=host, 297 user_agent=user_agent, 298 success=success, 299 expiry=expiry, 300 jwt_token=jwt_token) 301 302 if success: 303 use_oauth = 'Plex OAuth' if oauth else 'form' 304 logger.debug("Tautulli WebAuth :: %s user '%s' logged into Tautulli using %s login." 305 % (user_group.capitalize(), username, use_oauth)) 306 307 def on_logout(self, username, user_group, jwt_token=None): 308 """Called on logout""" 309 jwt_token = get_jwt_token() 310 if jwt_token: 311 Users().clear_user_login_token(jwt_token=jwt_token) 312 313 logger.debug("Tautulli WebAuth :: %s user '%s' logged out of Tautulli." % (user_group.capitalize(), username)) 314 315 def get_loginform(self, redirect_uri=''): 316 from plexpy.webserve import serve_template 317 return serve_template(templatename="login.html", title="Login", redirect_uri=unquote(redirect_uri)) 318 319 @cherrypy.expose 320 def index(self, *args, **kwargs): 321 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") 322 323 @cherrypy.expose 324 def login(self, redirect_uri='', *args, **kwargs): 325 self.check_auth_enabled() 326 327 return self.get_loginform(redirect_uri=redirect_uri) 328 329 @cherrypy.expose 330 def logout(self, redirect_uri='', *args, **kwargs): 331 self.check_auth_enabled() 332 333 payload = check_jwt_token() 334 if payload: 335 self.on_logout(username=payload['user'], 336 user_group=payload['user_group']) 337 338 jwt_cookie = str(JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID) 339 cherrypy.response.cookie[jwt_cookie] = '' 340 cherrypy.response.cookie[jwt_cookie]['expires'] = 0 341 cherrypy.response.cookie[jwt_cookie]['path'] = plexpy.HTTP_ROOT.rstrip('/') or '/' 342 343 if plexpy.HTTP_ROOT != '/': 344 # Also expire the JWT on the root path 345 cherrypy.response.headers['Set-Cookie'] = jwt_cookie + '=""; expires=Thu, 01 Jan 1970 12:00:00 GMT; path=/' 346 347 cherrypy.request.login = None 348 349 if redirect_uri: 350 redirect_uri = '?redirect_uri=' + redirect_uri 351 352 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login" + redirect_uri) 353 354 @cherrypy.expose 355 @cherrypy.tools.json_out() 356 def signin(self, username=None, password=None, token=None, remember_me='0', admin_login='0', *args, **kwargs): 357 if cherrypy.request.method != 'POST': 358 cherrypy.response.status = 405 359 return {'status': 'error', 'message': 'Sign in using POST.'} 360 361 ip_address = cherrypy.request.remote.ip 362 rate_limit = check_rate_limit(ip_address) 363 364 if rate_limit: 365 logger.debug("Tautulli WebAuth :: Too many incorrect login attempts from '%s'." % ip_address) 366 error_message = {'status': 'error', 'message': 'Too many login attempts.'} 367 cherrypy.response.status = 429 368 cherrypy.response.headers['Retry-After'] = rate_limit 369 return error_message 370 371 error_message = {'status': 'error', 'message': 'Invalid credentials.'} 372 373 valid_login, user_details, user_group = check_credentials(username=username, 374 password=password, 375 token=token, 376 admin_login=admin_login, 377 headers=kwargs) 378 379 if valid_login: 380 time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60) 381 expiry = datetime.utcnow() + time_delta 382 383 payload = { 384 'user_id': user_details['user_id'], 385 'user': user_details['username'], 386 'user_group': user_group, 387 'exp': expiry 388 } 389 390 jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM) 391 392 self.on_login(username=user_details['username'], 393 user_id=user_details['user_id'], 394 user_group=user_group, 395 success=True, 396 oauth=bool(token), 397 expiry=expiry, 398 jwt_token=jwt_token) 399 400 jwt_cookie = str(JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID) 401 cherrypy.response.cookie[jwt_cookie] = jwt_token 402 cherrypy.response.cookie[jwt_cookie]['expires'] = int(time_delta.total_seconds()) 403 cherrypy.response.cookie[jwt_cookie]['path'] = plexpy.HTTP_ROOT.rstrip('/') or '/' 404 cherrypy.response.cookie[jwt_cookie]['httponly'] = True 405 cherrypy.response.cookie[jwt_cookie]['samesite'] = 'lax' 406 407 cherrypy.request.login = payload 408 cherrypy.response.status = 200 409 return {'status': 'success', 'token': jwt_token, 'uuid': plexpy.CONFIG.PMS_UUID} 410 411 elif admin_login == '1' and username: 412 self.on_login(username=username) 413 logger.debug("Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username) 414 cherrypy.response.status = 401 415 return error_message 416 417 elif username: 418 self.on_login(username=username) 419 logger.debug("Tautulli WebAuth :: Invalid user login attempt from '%s'." % username) 420 cherrypy.response.status = 401 421 return error_message 422 423 elif token: 424 self.on_login(username='Plex OAuth', oauth=True) 425 logger.debug("Tautulli WebAuth :: Invalid Plex OAuth login attempt.") 426 cherrypy.response.status = 401 427 return error_message 428 429 @cherrypy.expose 430 def redirect(self, redirect_uri='', *args, **kwargs): 431 root = plexpy.HTTP_ROOT.rstrip('/') 432 if redirect_uri.startswith(root): 433 redirect_uri = redirect_uri[len(root):] 434 raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + redirect_uri.strip('/')) 435