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