aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ihatemoney/run.py6
-rw-r--r--ihatemoney/tests/tests.py23
-rw-r--r--ihatemoney/translations/fr/LC_MESSAGES/messages.mobin8425 -> 8629 bytes
-rw-r--r--ihatemoney/translations/fr/LC_MESSAGES/messages.po12
-rw-r--r--ihatemoney/utils.py39
-rw-r--r--ihatemoney/web.py22
6 files changed, 93 insertions, 9 deletions
diff --git a/ihatemoney/run.py b/ihatemoney/run.py
index 22cf235..1d02405 100644
--- a/ihatemoney/run.py
+++ b/ihatemoney/run.py
@@ -7,6 +7,7 @@ from flask_babel import Babel
from flask_mail import Mail
from flask_migrate import Migrate, upgrade, stamp
from raven.contrib.flask import Sentry
+from werkzeug.contrib.fixers import ProxyFix
from ihatemoney.api import api
from ihatemoney.models import db
@@ -104,6 +105,11 @@ def create_app(configuration=None, instance_path='/etc/ihatemoney',
load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app)
+ # Get client's real IP
+ # Note(0livd): When running in a non-proxy setup, is vulnerable to requests
+ # with a forged X-FORWARDED-FOR header
+ app.wsgi_app = ProxyFix(app.wsgi_app)
+
validate_configuration(app)
app.register_blueprint(web_interface)
app.register_blueprint(api)
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py
index 5fc45a7..86f11f3 100644
--- a/ihatemoney/tests/tests.py
+++ b/ihatemoney/tests/tests.py
@@ -9,6 +9,7 @@ import os
import json
from collections import defaultdict
import six
+from time import sleep
from werkzeug.security import generate_password_hash
from flask import session
@@ -397,6 +398,28 @@ class BudgetTestCase(IhatemoneyTestCase):
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': ''})
self.assertNotIn('<a href="/create">/create</a>', resp.data.decode('utf-8'))
+ def test_login_throttler(self):
+ self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
+
+ # Authenticate 3 times with a wrong passsword
+ self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
+ self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
+ resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
+
+ self.assertIn('Too many failed login attempts, please retry later.',
+ resp.data.decode('utf-8'))
+ # Change throttling delay
+ import gc
+ for obj in gc.get_objects():
+ if isinstance(obj, utils.LoginThrottler):
+ obj._delay = 0.005
+ break
+ # Wait for delay to expire and retry logging in
+ sleep(1)
+ resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
+ self.assertNotIn('Too many failed login attempts, please retry later.',
+ resp.data.decode('utf-8'))
+
def test_manage_bills(self):
self.post_project("raclette")
diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo
index 210852b..2f46b71 100644
--- a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo
+++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
index 0f3339e..65c295d 100644
--- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
@@ -163,13 +163,17 @@ msgid "Export file format"
msgstr "Format du fichier d'export"
#: web.py:95
-msgid "This admin password is not the right one"
-msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct"
-
-#: web.py:95
msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct"
+#: web.py:106
+msgid "This admin password is not the right one. Only %(num)d attempts left."
+msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct. Plus que %(num)d tentatives."
+
+#: web.py:106
+msgid "Too many failed login attempts, please retry later."
+msgstr "Trop d'échecs d'authentification successifs, veuillez réessayer plus tard."
+
#: web.py:147
#, python-format
msgid "You have just created '%(project)s' to share your expenses"
diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py
index 4e79a37..6af0112 100644
--- a/ihatemoney/utils.py
+++ b/ihatemoney/utils.py
@@ -7,6 +7,7 @@ from json import dumps
from flask import redirect
from werkzeug.routing import HTTPException, RoutingException
import six
+from datetime import datetime, timedelta
import csv
@@ -131,3 +132,41 @@ def list_of_dicts2csv(dict_to_convert):
# base64 encoding that works with both py2 and py3 and yield no warning
base64_encode = base64.encodestring if six.PY2 else base64.encodebytes
+
+
+class LoginThrottler():
+ """Simple login throttler used to limit authentication attempts based on client's ip address.
+ When using multiple workers, remaining number of attempts can get inconsistent
+ but will still be limited to num_workers * max_attempts.
+ """
+ def __init__(self, max_attempts=3, delay=1):
+ self._max_attempts = max_attempts
+ # Delay in minutes before resetting the attempts counter
+ self._delay = delay
+ self._attempts = {}
+
+ def get_remaining_attempts(self, ip):
+ return self._max_attempts - self._attempts.get(ip, [datetime.now(), 0])[1]
+
+ def increment_attempts_counter(self, ip):
+ # Reset all attempt counters when they get hungry for memory
+ if len(self._attempts) > 10000:
+ self.__init__()
+ if self._attempts.get(ip) is None:
+ # Store first attempt date and number of attempts since
+ self._attempts[ip] = [datetime.now(), 0]
+ self._attempts.get(ip)[1] += 1
+
+ def is_login_allowed(self, ip):
+ if self._attempts.get(ip) is None:
+ return True
+ # When the delay is expired, reset the counter
+ if datetime.now() - self._attempts.get(ip)[0] > timedelta(minutes=self._delay):
+ self.reset(ip)
+ return True
+ if self._attempts.get(ip)[1] >= self._max_attempts:
+ return False
+ return True
+
+ def reset(self, ip):
+ self._attempts.pop(ip, None)
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index 65c0ed6..cc2eeac 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -27,10 +27,12 @@ from ihatemoney.forms import (
InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for,
ExportForm
)
-from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv
+from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler
main = Blueprint("main", __name__)
+login_throttler = LoginThrottler(max_attempts=3, delay=1)
+
def requires_admin(f):
"""Require admin permissions for @requires_admin decorated endpoints.
@@ -89,14 +91,24 @@ def admin():
form = AdminAuthenticationForm()
goto = request.args.get('goto', url_for('.home'))
if request.method == "POST":
+ client_ip = request.remote_addr
+ if not login_throttler.is_login_allowed(client_ip):
+ msg = _("Too many failed login attempts, please retry later.")
+ form.errors['admin_password'] = [msg]
+ return render_template("authenticate.html", form=form, admin_auth=True)
if form.validate():
- if check_password_hash(current_app.config['ADMIN_PASSWORD'], form.admin_password.data):
+ # Valid password
+ if (check_password_hash(current_app.config['ADMIN_PASSWORD'],
+ form.admin_password.data)):
session['is_admin'] = True
session.update()
+ login_throttler.reset(client_ip)
return redirect(goto)
- else:
- msg = _("This admin password is not the right one")
- form.errors['admin_password'] = [msg]
+ # Invalid password
+ login_throttler.increment_attempts_counter(client_ip)
+ msg = _("This admin password is not the right one. Only %(num)d attempts left.",
+ num=login_throttler.get_remaining_attempts(client_ip))
+ form.errors['admin_password'] = [msg]
return render_template("authenticate.html", form=form, admin_auth=True)