diff --git a/CHANGELOG b/CHANGELOG index 73a55a9409..903757763a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Limit authentication attempt per IP network * Manage 'X-Forwarded' headers from proxies * Add '_request' attribute to Transaction context * Delete translations on deletion only when model has translatable field diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst index 1835e5f6c1..ec14ab8571 100644 --- a/doc/topics/configuration.rst +++ b/doc/topics/configuration.rst @@ -262,6 +262,31 @@ attempts over a period of `timeout`. Default: `5` +max_attempt_ip_network +~~~~~~~~~~~~~~~~~~~~~~ + +The maximum authentication attempt from the same network before the server +answers unconditionally `Too Many Requests` for any other attempts. The +counting is done on all attempts over a period of `timeout`. + +Default: `300` + +ip_network_4 +~~~~~~~~~~~~ + +The network prefix to apply on IPv4 address for counting the authentication +attempts. + +Default: `32` + +ip_network_6 +~~~~~~~~~~~~ + +The network prefix to apply on IPv6 address for counting the authentication +attempts. + +Default: `56` + password -------- diff --git a/doc/topics/install.rst b/doc/topics/install.rst index fae2df00be..9057bc8e74 100644 --- a/doc/topics/install.rst +++ b/doc/topics/install.rst @@ -16,6 +16,7 @@ Prerequisites * python-dateutil (http://labix.org/python-dateutil) * polib (https://bitbucket.org/izi/polib/wiki/Home) * python-sql 0.4 or later (http://code.google.com/p/python-sql/) + * ipaddress (https://github.com/phihag/ipaddress) * Optional: psycopg 2.5.0 or later (http://www.initd.org/) * Optional: psycopg2cffi 2.5.0 or later (http://github.com/chtd/psycopg2cffi) diff --git a/setup.py b/setup.py index e168445ba6..f2a8b69034 100644 --- a/setup.py +++ b/setup.py @@ -100,6 +100,7 @@ setup(name=name, 'python-sql >= 0.4', 'werkzeug', 'wrapt', + 'ipaddress', ], extras_require={ 'PostgreSQL': pg_require, diff --git a/trytond/config.py b/trytond/config.py index 7dd0db1a87..e2cdb15a78 100644 --- a/trytond/config.py +++ b/trytond/config.py @@ -67,6 +67,9 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): self.set('session', 'authentications', 'password') self.set('session', 'timeout', 600) self.set('session', 'max_attempt', 5) + self.set('session', 'max_attempt_ip_network', 300) + self.set('session', 'ip_network_4', 32) + self.set('session', 'ip_network_6', 56) self.add_section('password') self.set('password', 'length', 8) self.set('password', 'entropy', 0.75) diff --git a/trytond/res/user.py b/trytond/res/user.py index beb25715ac..83be90843a 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -11,6 +11,7 @@ import datetime import logging import uuid import mmap +import ipaddress from functools import wraps from itertools import groupby, ifilter from operator import attrgetter @@ -540,6 +541,11 @@ class User(ModelSQL, ModelView): Return user id if password matches ''' LoginAttempt = Pool().get('res.user.login.attempt') + count_ip = LoginAttempt.count_ip() + if count_ip > config.getint( + 'session', 'max_attempt_ip_network', default=300): + # Do not add attempt as the goal is to prevent flooding + raise RateLimitException() count = LoginAttempt.count(login) if count > config.getint('session', 'max_attempt', default=5): LoginAttempt.add(login) @@ -634,6 +640,8 @@ class LoginAttempt(ModelSQL): """ __name__ = 'res.user.login.attempt' login = fields.Char('Login', size=512) + ip_address = fields.Char("IP Address") + ip_network = fields.Char("IP Network") @classmethod def __register__(cls, module_name): @@ -649,6 +657,21 @@ class LoginAttempt(ModelSQL): return (datetime.datetime.now() - datetime.timedelta(seconds=config.getint('session', 'timeout'))) + @classmethod + def ipaddress(cls): + context = Transaction().context + ip_address = '' + ip_network = '' + if context.get('_request') and context['_request'].get('remote_addr'): + ip_address = ipaddress.ip_address( + unicode(context['_request']['remote_addr'])) + prefix = config.getint( + 'session', 'ip_network_%s' % ip_address.version) + ip_network = ipaddress.ip_network( + unicode(context['_request']['remote_addr'])) + ip_network = ip_network.supernet(new_prefix=prefix) + return ip_address, ip_network + def _login_size(func): @wraps(func) def wrapper(cls, login, *args, **kwargs): @@ -662,7 +685,12 @@ class LoginAttempt(ModelSQL): table = cls.__table__() cursor.execute(*table.delete(where=table.create_date < cls.delay())) - cls.create([{'login': login}]) + ip_address, ip_network = cls.ipaddress() + cls.create([{ + 'login': login, + 'ip_address': str(ip_address), + 'ip_network': str(ip_network), + }]) @classmethod @_login_size @@ -676,11 +704,21 @@ class LoginAttempt(ModelSQL): def count(cls, login): cursor = Transaction().connection.cursor() table = cls.__table__() - cursor.execute(*table.select(Count(Literal(1)), + cursor.execute(*table.select(Count(Literal('*')), where=(table.login == login) & (table.create_date >= cls.delay()))) return cursor.fetchone()[0] + @classmethod + def count_ip(cls): + cursor = Transaction().connection.cursor() + table = cls.__table__() + _, ip_network = cls.ipaddress() + cursor.execute(*table.select(Count(Literal('*')), + where=(table.ip_network == str(ip_network)) + & (table.create_date >= cls.delay()))) + return cursor.fetchone()[0] + del _login_size