Skip to content

Commit

Permalink
Add rate limiting for failed login attempts
Browse files Browse the repository at this point in the history
  • Loading branch information
ThiefMaster committed Mar 12, 2021
1 parent 053471c commit 5d6c131
Show file tree
Hide file tree
Showing 7 changed files with 53 additions and 5 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Expand Up @@ -38,6 +38,8 @@ Improvements
in a secure and anonymous way that does not disclose any data. If a user logs in with
an insecure password, they are forced to change it before they can continue using Indico
(:pr:`4817`)
- Failed login attempts now trigger rate limiting to prevent brute-force attacks
(:issue:`1550`, :pr:`4817`)

Internal Changes
^^^^^^^^^^^^^^^^
Expand Down
20 changes: 20 additions & 0 deletions docs/source/config/settings.rst
Expand Up @@ -65,6 +65,26 @@ Authentication

Default: ``False``

.. data:: FAILED_LOGIN_RATE_LIMIT

Applies a rate limit to failed login attempts due to an invalid username
or password. When specifying multiple rate limits separated with a semicolon,
they are checked in that specific order, which can allow for a short burst of
attempts (e.g. a legitimate user trying multiple passwords they commonly use)
and then slowing down more strongly (in case someone tries to brute-force more
than just a few passwords).

Rate limiting is applied by IP address and only failed logins count against the
rate limit. It also does not apply to login attempts using external login systems
(SSO) as failures there are rarely related to invalid credentials coming from the
user (these would be rejected on the SSO side, which should implement its own rate
limiting).

The default allows a burst of 15 attempts, and then only 5 attempts every 15
minutes for the next 24 hours. Setting the rate limit to ``None`` disables it.

Default: ``'5 per 15 minutes; 10 per day'``

.. data:: EXTERNAL_REGISTRATION_URL

The URL to an external page where people can register an account that
Expand Down
7 changes: 7 additions & 0 deletions indico/core/auth.py
Expand Up @@ -5,13 +5,19 @@
# modify it under the terms of the MIT License; see the
# LICENSE file for more details.

import functools

from flask import current_app, request
from flask_multipass import InvalidCredentials, Multipass, NoSuchUser
from werkzeug.local import LocalProxy

from indico.core.config import config
from indico.core.limiter import make_rate_limiter
from indico.core.logger import Logger


logger = Logger.get('auth')
login_rate_limiter = LocalProxy(functools.cache(lambda: make_rate_limiter('login', config.FAILED_LOGIN_RATE_LIMIT)))


class IndicoMultipass(Multipass):
Expand Down Expand Up @@ -73,6 +79,7 @@ def _check_default_provider(self):

def handle_auth_error(self, exc, redirect_to_login=False):
if isinstance(exc, (NoSuchUser, InvalidCredentials)):
login_rate_limiter.hit()
logger.warning('Invalid credentials (ip=%s, provider=%s): %s',
request.remote_addr, exc.provider.name if exc.provider else None, exc)
else:
Expand Down
9 changes: 7 additions & 2 deletions indico/core/config.py
Expand Up @@ -49,6 +49,7 @@
'EXPERIMENTAL_EDITING_SERVICE': False,
'EXTERNAL_REGISTRATION_URL': None,
'HELP_URL': 'https://learn.getindico.io',
'FAILED_LOGIN_RATE_LIMIT': '5 per 15 minutes; 10 per day',
'IDENTITY_PROVIDERS': {},
'LOCAL_IDENTITIES': True,
'LOCAL_MODERATION': False,
Expand Down Expand Up @@ -157,8 +158,6 @@ def _convert_key(name):


def _postprocess_config(data):
if data['DEFAULT_TIMEZONE'] not in pytz.all_timezones_set:
raise ValueError('Invalid default timezone: {}'.format(data['DEFAULT_TIMEZONE']))
data['BASE_URL'] = data['BASE_URL'].rstrip('/')
data['STATIC_SITE_STORAGE'] = data['STATIC_SITE_STORAGE'] or data['ATTACHMENT_STORAGE']
if data['DISABLE_CELERY_CHECK'] is None:
Expand Down Expand Up @@ -258,6 +257,12 @@ def IMAGES_BASE_URL(self):
def LATEX_ENABLED(self):
return bool(self.XELATEX_PATH)

def validate(self):
from indico.core.auth import login_rate_limiter
login_rate_limiter._get_current_object() # fail in case FAILED_LOGIN_RATE_LIMIT invalid
if self.DEFAULT_TIMEZONE not in pytz.all_timezones_set:
raise ValueError(f'Invalid default timezone: {self.DEFAULT_TIMEZONE}')

def __getattr__(self, name):
try:
return self.data[name]
Expand Down
11 changes: 8 additions & 3 deletions indico/modules/auth/controllers.py
Expand Up @@ -12,7 +12,7 @@
from werkzeug.exceptions import BadRequest, Forbidden, NotFound

from indico.core import signals
from indico.core.auth import multipass
from indico.core.auth import login_rate_limiter, multipass
from indico.core.config import config
from indico.core.db import db
from indico.core.notifications import make_email, send_email
Expand Down Expand Up @@ -85,21 +85,26 @@ def _process(self):
return provider.initiate_external_login()

# If we have a POST request we submitted a login form for a local provider
rate_limit_exceeded = False
if request.method == 'POST':
active_provider = provider = _get_provider(request.form['_provider'], False)
form = provider.login_form()
if form.validate_on_submit():
rate_limit_exceeded = not login_rate_limiter.test()
if not rate_limit_exceeded and form.validate_on_submit():
response = multipass.handle_login_form(provider, form.data)
if response:
return response
# re-check since a failed login may have triggered the rate limit
rate_limit_exceeded = not login_rate_limiter.test()
# Otherwise we show the form for the default provider
else:
active_provider = multipass.default_local_auth_provider
form = active_provider.login_form() if active_provider else None

providers = list(multipass.auth_providers.values())
retry_in = login_rate_limiter.get_reset_delay() if rate_limit_exceeded else None
return render_template('auth/login_page.html', form=form, providers=providers, active_provider=active_provider,
login_reason=login_reason)
login_reason=login_reason, retry_in=retry_in)


class RHLoginForm(RH):
Expand Down
8 changes: 8 additions & 0 deletions indico/modules/auth/templates/login_page.html
@@ -1,5 +1,6 @@
{% extends 'layout/auth_base.html' %}
{% from 'auth/_login_form.html' import login_form %}
{% from 'message_box.html' import message_box %}

{% block content %}
<div class="centered-column">
Expand All @@ -12,6 +13,13 @@
</div>
{% endif %}
{% include 'flashed_messages.html' %}
{% if retry_in %}
{% call message_box('error') %}
{% trans delay=retry_in|format_human_timedelta(granularity='minutes') %}
Too many failed login attempts. Please wait {{ delay }}.
{% endtrans %}
{% endcall %}
{% endif %}
<div id="form-wrapper">
{{ login_form(active_provider, form) }}
</div>
Expand Down
1 change: 1 addition & 0 deletions indico/web/flask/app.py
Expand Up @@ -400,4 +400,5 @@ def make_app(testing=False, config_override=None):
add_plugin_blueprints(app)
# themes can be provided by plugins
signals.app_created.send(app)
config.validate()
return app

0 comments on commit 5d6c131

Please sign in to comment.