diff --git a/CHANGELOG b/CHANGELOG index c6a619edcc..7ec6f8c4ad 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Return 429 status when too many login attempts * Add set_rpc on Field * Add has_select_for on Database * Store custom report translation in separate module diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst index 9740640a84..97052d9251 100644 --- a/doc/topics/configuration.rst +++ b/doc/topics/configuration.rst @@ -219,6 +219,15 @@ The time in seconds until a session expires. Default: `600` +max_attempt +~~~~~~~~~~~ + +The maximum authentication attempt 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: `5` + report ------ diff --git a/trytond/config.py b/trytond/config.py index 0ee5eecfcc..464f259e1a 100644 --- a/trytond/config.py +++ b/trytond/config.py @@ -62,6 +62,7 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): self.add_section('session') self.set('session', 'authentications', 'password') self.set('session', 'timeout', 600) + self.set('session', 'max_attempt', 5) self.add_section('report') self.set('report', 'unoconv', 'pipe,name=trytond;urp;StarOffice.ComponentContext') diff --git a/trytond/exceptions.py b/trytond/exceptions.py index 86cb32e181..a29237bb25 100644 --- a/trytond/exceptions.py +++ b/trytond/exceptions.py @@ -47,3 +47,7 @@ class ConcurrencyException(TrytonException): message) self.message = message self.code = 4 + + +class RateLimitException(TrytonException): + """User has sent too many requests in a given amount of time.""" diff --git a/trytond/protocols/dispatcher.py b/trytond/protocols/dispatcher.py index fe81f6a4a1..0fb1e5f1c1 100644 --- a/trytond/protocols/dispatcher.py +++ b/trytond/protocols/dispatcher.py @@ -15,7 +15,8 @@ from trytond import __version__ from trytond.transaction import Transaction from trytond.cache import Cache from trytond.exceptions import ( - UserError, UserWarning, ConcurrencyException, LoginException) + UserError, UserWarning, ConcurrencyException, LoginException, + RateLimitException) from trytond.tools import is_instance_method from trytond.wsgi import app from .wrappers import with_pool @@ -49,8 +50,13 @@ def login(request, database_name, user, parameters, language=None): except DatabaseOperationalError: logger.error('fail to connect to %s', database_name, exc_info=True) abort(404) - session = security.login( - database_name, user, parameters, language=language) + try: + session = security.login( + database_name, user, parameters, language=language) + code = 403 + except RateLimitException: + session = None + code = 429 with Transaction().start(database_name, 0): Cache.clean(database_name) Cache.resets(database_name) @@ -58,7 +64,7 @@ def login(request, database_name, user, parameters, language=None): logger.info('%s \'%s\' from %s using %s on database \'%s\'', msg, user, request.remote_addr, request.scheme, database_name) if not session: - abort(403) + abort(code) return session diff --git a/trytond/res/routes.py b/trytond/res/routes.py index db6dd3ad42..06c5b2a004 100644 --- a/trytond/res/routes.py +++ b/trytond/res/routes.py @@ -4,6 +4,9 @@ import logging import time import random +from werkzeug.exceptions import abort + +from trytond.config import config from trytond.wsgi import app from trytond.protocols.wrappers import with_pool, with_transaction @@ -46,7 +49,11 @@ def user_application(request, pool): ])) return key elif request.method == 'DELETE': - time.sleep(2 ** LoginAttempt.count(login) - 1) + count = LoginAttempt.count(login) + if count > config.get('session', 'max_attempt', default=5): + LoginAttempt.add(login) + abort(429) + time.sleep(2 ** count - 1) applications = UserApplication.search([ ('user.login', '=', login), ('key', '=', data.get('key')), diff --git a/trytond/res/user.py b/trytond/res/user.py index 9dd5f54472..16f841a2fa 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -34,7 +34,7 @@ from ..pool import Pool from ..config import config from ..pyson import PYSONEncoder, Eval from ..rpc import RPC -from ..exceptions import LoginException +from ..exceptions import LoginException, RateLimitException __all__ = [ 'User', 'LoginAttempt', 'UserAction', 'UserGroup', 'Warning_', @@ -496,7 +496,11 @@ class User(ModelSQL, ModelView): Return user id if password matches ''' LoginAttempt = Pool().get('res.user.login.attempt') - time.sleep(2 ** LoginAttempt.count(login) - 1) + count = LoginAttempt.count(login) + if count > config.get('session', 'max_attempt', default=5): + LoginAttempt.add(login) + raise RateLimitException() + time.sleep(2 ** count - 1) for method in config.get( 'session', 'authentications', default='password').split(','): try: diff --git a/trytond/security.py b/trytond/security.py index b9adc45c1d..d911f87b8e 100644 --- a/trytond/security.py +++ b/trytond/security.py @@ -4,7 +4,7 @@ from trytond.pool import Pool from trytond.config import config from trytond.transaction import Transaction from trytond import backend -from trytond.exceptions import LoginException +from trytond.exceptions import LoginException, RateLimitException def _get_pool(dbname): @@ -28,7 +28,7 @@ def login(dbname, loginname, parameters, cache=True, language=None): if count: continue raise - except LoginException: + except (LoginException, RateLimitException): # Let's store any changes done transaction.commit() raise