diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index d32fb01d..d466536f 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -204,7 +204,7 @@ ${next.modalIncludes()} @@ -446,12 +446,16 @@ ${next.modalIncludes()} data: $(this).serialize(), dataType: 'json', statusCode: { - 200: function() { + 200: function(xhr, status) { window.location = "${http_root}"; }, - 401: function() { - $('#incorrect-login').show(); - $('#username').focus(); + 401: function(xhr, status) { + $('#sign-in-alert').text('Incorrect username or password.').show(); + $('#username').focus(); + }, + 429: function(xhr, status) { + var retry = Math.ceil(xhr.getResponseHeader('Retry-After') / 60) + $('#sign-in-alert').text('Too many login attempts. Try again in ' + retry + ' minute(s).').show(); } }, complete: function() { diff --git a/data/interfaces/default/login.html b/data/interfaces/default/login.html index 648402ca..f49ad8b4 100644 --- a/data/interfaces/default/login.html +++ b/data/interfaces/default/login.html @@ -159,16 +159,20 @@ data: data, dataType: 'json', statusCode: { - 200: function() { + 200: function(xhr, status) { window.location = "${redirect_uri or http_root}"; }, - 401: function() { + 401: function(xhr, status) { if (plex) { $('#sign-in-alert').text('Invalid Plex Login.').show(); } else { $('#sign-in-alert').text('Incorrect username or password.').show(); $('#username').focus(); } + }, + 429: function(xhr, status) { + var retry = Math.ceil(xhr.getResponseHeader('Retry-After') / 60) + $('#sign-in-alert').text('Too many login attempts. Try again in ' + retry + ' minute(s).').show(); } }, complete: function() { diff --git a/plexpy/config.py b/plexpy/config.py index 25189527..db81dff1 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -134,6 +134,9 @@ _CONFIG_DEFINITIONS = { 'HTTP_USERNAME': (str, 'General', ''), 'HTTP_PLEX_ADMIN': (int, 'General', 0), 'HTTP_BASE_URL': (str, 'General', ''), + 'HTTP_RATE_LIMIT_ATTEMPTS': (int, 'General', 10), + 'HTTP_RATE_LIMIT_ATTEMPTS_INTERVAL': (int, 'General', 300), + 'HTTP_RATE_LIMIT_LOCKOUT_TIME': (int, 'General', 300), 'INTERFACE': (str, 'General', 'default'), 'IMGUR_CLIENT_ID': (str, 'Monitoring', ''), 'JOURNAL_MODE': (str, 'Advanced', 'WAL'), diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 54ea7a0c..6fb8c10a 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -33,11 +33,13 @@ import plexpy if plexpy.PYTHON2: import logger from database import MonitorDatabase + from helpers import timestamp from users import Users, refresh_users from plextv import PlexTV else: from plexpy import logger from plexpy.database import MonitorDatabase + from plexpy.helpers import timestamp from plexpy.users import Users, refresh_users from plexpy.plextv import PlexTV @@ -246,6 +248,20 @@ def all_of(*conditions): return check +def check_rate_limit(ip_address): + monitor_db = MonitorDatabase() + result = monitor_db.select('SELECT timestamp FROM user_login ' + 'WHERE ip_address = ? AND success = 0 ' + 'AND timestamp >= (SELECT MAX(timestamp) FROM user_login WHERE success = 1) ' + 'AND timestamp > (SELECT MAX(timestamp) - ? FROM user_login) ' + 'ORDER BY timestamp DESC', + [ip_address, plexpy.CONFIG.HTTP_RATE_LIMIT_ATTEMPTS_INTERVAL]) + + if len(result) >= plexpy.CONFIG.HTTP_RATE_LIMIT_ATTEMPTS: + last_timestamp = result[0]['timestamp'] + return max(last_timestamp - (timestamp() - plexpy.CONFIG.HTTP_RATE_LIMIT_LOCKOUT_TIME), 0) + + # Controller to provide login and logout actions class AuthController(object): @@ -325,6 +341,16 @@ class AuthController(object): cherrypy.response.status = 405 return {'status': 'error', 'message': 'Sign in using POST.'} + ip_address = cherrypy.request.remote.ip + rate_limit = check_rate_limit(ip_address) + + if rate_limit: + logger.debug("Tautulli WebAuth :: Too many incorrect login attempts from '%s'." % ip_address) + error_message = {'status': 'error', 'message': 'Too many login attempts.'} + cherrypy.response.status = 429 + cherrypy.response.headers['Retry-After'] = rate_limit + return error_message + error_message = {'status': 'error', 'message': 'Invalid credentials.'} valid_login, user_details, user_group = check_credentials(username=username,