summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXavier Morel <xmo@odoo.com>2018-01-12 15:50:12 +0100
committerXavier Morel <xmo@odoo.com>2018-07-26 15:53:26 +0200
commita8d868e2877e12e2c5c6329055f6c8568d286331 (patch)
tree410fb43e22b8a32d1626f3657f1c45bc7077cd8a
parent48c058112f840992947551cfe3c74f6f448bf1fe (diff)
[ADD] Rate limiting to (failed) login attempts
Task 31122 section 4. Implement per-IP rate limiting of login attempts after some number of failures. * check_credentials has no reason to be public, make it private * add hooks to check for login cooldown on a source IP (remote_addr: http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.remote_addr) basis * add baseline/default configuration of 60s cooldown * add baseline threshold of 10 login failures, after checking odoo.com logs it looks like we have short runs of up to 7 failures (assumed to be legitimate) before the user either gets it right or goes and looks it up Depends on #24187
-rw-r--r--addons/auth_ldap/models/res_users.py5
-rw-r--r--addons/auth_oauth/models/res_users.py5
-rw-r--r--addons/web/controllers/main.py7
-rw-r--r--addons/website_sale_wishlist/models/res_users.py5
-rw-r--r--odoo/addons/base/models/ir_config_parameter.py2
-rw-r--r--odoo/addons/base/models/ir_http.py2
-rw-r--r--odoo/addons/base/models/res_users.py137
7 files changed, 141 insertions, 22 deletions
diff --git a/addons/auth_ldap/models/res_users.py b/addons/auth_ldap/models/res_users.py
index 83887f802a0..9e783064557 100644
--- a/addons/auth_ldap/models/res_users.py
+++ b/addons/auth_ldap/models/res_users.py
@@ -29,10 +29,9 @@ class Users(models.Model):
break
return user_id
- @api.model
- def check_credentials(self, password):
+ def _check_credentials(self, password):
try:
- super(Users, self).check_credentials(password)
+ super(Users, self)._check_credentials(password)
except AccessDenied:
if self.env.user.active:
Ldap = self.env['res.company.ldap']
diff --git a/addons/auth_oauth/models/res_users.py b/addons/auth_oauth/models/res_users.py
index 3657885c3ac..0e54a23b0aa 100644
--- a/addons/auth_oauth/models/res_users.py
+++ b/addons/auth_oauth/models/res_users.py
@@ -109,10 +109,9 @@ class ResUsers(models.Model):
# return user credentials
return (self.env.cr.dbname, login, access_token)
- @api.model
- def check_credentials(self, password):
+ def _check_credentials(self, password):
try:
- return super(ResUsers, self).check_credentials(password)
+ return super(ResUsers, self)._check_credentials(password)
except AccessDenied:
res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)])
if not res:
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index bf27842d55e..1269c175cd0 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -482,13 +482,18 @@ class Home(http.Controller):
values['databases'] = None
if request.httprequest.method == 'POST':
+ # ensure there's no leftover in the session from non-web auth
+ request.session.pop('login_error', None)
old_uid = request.uid
uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password'])
if uid is not False:
request.params['login_success'] = True
return http.redirect_with_hash(self._login_redirect(uid, redirect=redirect))
request.uid = old_uid
- values['error'] = _("Wrong login/password")
+ values['error'] = request.session.pop(
+ 'login_error',
+ _("Wrong login/password")
+ )
else:
if 'error' in request.params and request.params.get('error') == 'access':
values['error'] = _('Only employee can access this database. Please contact the administrator.')
diff --git a/addons/website_sale_wishlist/models/res_users.py b/addons/website_sale_wishlist/models/res_users.py
index 489ede52090..234de4509f6 100644
--- a/addons/website_sale_wishlist/models/res_users.py
+++ b/addons/website_sale_wishlist/models/res_users.py
@@ -16,9 +16,8 @@ class ResUsers(models.Model):
except (AttributeError, RuntimeError):
pass # Unbound session, value is already False, nothing to do
- @api.model
- def check_credentials(self, password):
+ def _check_credentials(self, password):
"""Make all this session's wishlists belong to its owner user."""
- result = super(ResUsers, self).check_credentials(password)
+ result = super(ResUsers, self)._check_credentials(password)
self.env["product.wishlist"]._join_current_user_and_session()
return result
diff --git a/odoo/addons/base/models/ir_config_parameter.py b/odoo/addons/base/models/ir_config_parameter.py
index d3d6b6f0554..1c0ca277513 100644
--- a/odoo/addons/base/models/ir_config_parameter.py
+++ b/odoo/addons/base/models/ir_config_parameter.py
@@ -20,6 +20,8 @@ _default_parameters = {
"database.uuid": lambda: pycompat.text_type(uuid.uuid1()),
"database.create_date": fields.Datetime.now,
"web.base.url": lambda: "http://localhost:%s" % config.get('http_port'),
+ "base.login_cooldown_after": lambda: 10,
+ "base.login_cooldown_duration": lambda: 60,
}
diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py
index 751f4aed9ee..6fab91e7498 100644
--- a/odoo/addons/base/models/ir_http.py
+++ b/odoo/addons/base/models/ir_http.py
@@ -108,7 +108,7 @@ class IrHttp(models.AbstractModel):
request.session.check_security()
# what if error in security.check()
# -> res_users.check()
- # -> res_users.check_credentials()
+ # -> res_users._check_credentials()
except (AccessDenied, http.SessionExpiredException):
# All other exceptions mean undetermined status (e.g. connection pool full),
# let them bubble up
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index f4c6cfea0cc..43b0ebba856 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import contextlib
+
import pytz
import datetime
+import ipaddress
import itertools
import logging
import hmac
@@ -18,7 +21,7 @@ from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationErro
from odoo.http import request
from odoo.osv import expression
from odoo.service.db import check_super
-from odoo.tools import partition, pycompat
+from odoo.tools import partition, pycompat, collections
_logger = logging.getLogger(__name__)
@@ -287,8 +290,21 @@ class Users(models.Model):
)
self.invalidate_cache(['password'], [uid])
- @api.model
- def check_credentials(self, password):
+ def _check_credentials(self, password):
+ """ Validates the current user's password.
+
+ Override this method to plug additional authentication methods.
+
+ Overrides should:
+
+ * call `super` to delegate to parents for credentials-checking
+ * catch AccessDenied and perform their own checking
+ * (re)raise AccessDenied if the credentials are still invalid
+ according to their own validation method
+
+ When trying to check for credentials validity, call _check_credentials
+ instead.
+ """
""" Override this method to plug additional authentication methods"""
self.env.cr.execute(
'SELECT password FROM res_users WHERE id=%s',
@@ -534,11 +550,12 @@ class Users(models.Model):
try:
with cls.pool.cursor() as cr:
self = api.Environment(cr, SUPERUSER_ID, {})[cls._name]
- user = self.search([('login', '=', login)])
- if user:
- user_id = user.id
- user.sudo(user_id).check_credentials(password)
- user.sudo(user_id)._update_last_login()
+ with self._assert_can_auth():
+ user = self.search([('login', '=', login)])
+ if user:
+ user_id = user.id
+ user.sudo(user_id)._check_credentials(password)
+ user.sudo(user_id)._update_last_login()
except AccessDenied:
user_id = False
@@ -587,8 +604,9 @@ class Users(models.Model):
cr = cls.pool.cursor()
try:
self = api.Environment(cr, uid, {})[cls._name]
- self.check_credentials(passwd)
- cls.__uid_cache[db][uid] = passwd
+ with self._assert_can_auth():
+ self._check_credentials(passwd)
+ cls.__uid_cache[db][uid] = passwd
finally:
cr.close()
@@ -817,6 +835,104 @@ class Users(models.Model):
if groups_id_vals:
user.write({'groups_id': groups_id_vals})
+ @contextlib.contextmanager
+ def _assert_can_auth(self):
+ """ Checks that the current environment even allows the current auth
+ request to happen.
+
+ The baseline implementation is a simple linear login cooldown: after
+ a number of failures trying to log-in, the user (by login) is put on
+ cooldown. During the cooldown period, login *attempts* are ignored
+ and logged.
+
+ .. warning::
+
+ The login counter is not shared between workers and not
+ specifically thread-safe, the feature exists mostly for
+ rate-limiting on large number of login attempts (brute-forcing
+ passwords) so that should not be much of an issue.
+
+ For a more complex strategy (e.g. database or distribute storage)
+ override this method. To simply change the cooldown criteria
+ (configuration, ...) override _on_login_cooldown instead.
+
+ .. note::
+
+ This is a *context manager* so it can be called around the login
+ procedure without having to call it itself.
+ """
+ # needs request for remote address
+ if not request:
+ yield
+ return
+
+ reg = self.env.registry
+ failures_map = getattr(reg, '_login_failures', None)
+ if failures_map is None:
+ failures_map = reg._login_failures = collections.defaultdict(lambda : (0, datetime.datetime.min))
+
+ source = request.httprequest.remote_addr
+ (failures, previous) = failures_map[source]
+ if self._on_login_cooldown(failures, previous):
+ request.session['login_error'] = _("Too many login failures, please wait a bit before trying again.")
+
+ _logger.warn(
+ "Login attempt ignored for %s on %s: "
+ "%d failures since last success, last failure at %s. "
+ "You can configure the number of login failures before a "
+ "user is put on cooldown as well as the duration in the "
+ "System Parameters. Disable this feature by setting "
+ "\"base.login_cooldown_after\" to 0.",
+ source, self.env.cr.dbname, failures, previous)
+ if ipaddress.ip_address(source).is_private:
+ _logger.warn(
+ "The rate-limited IP address %s is classified as private "
+ "and *might* be a proxy. If your Odoo is behind a proxy, "
+ "it may be mis-configured. Check that you are running "
+ "Odoo in Proxy Mode and that the proxy is properly configured, see "
+ "https://www.odoo.com/documentation/11.0/setup/deploy.html#https for details.",
+ source
+ )
+ raise AccessDenied()
+
+ try:
+ yield
+ except AccessDenied:
+ (failures, __) = reg._login_failures[source]
+ reg._login_failures[source] = (failures + 1, datetime.datetime.now())
+ raise
+ else:
+ reg._login_failures.pop(source, None)
+
+ def _on_login_cooldown(self, failures, previous):
+ """ Decides whether the user trying to log in is currently
+ "on cooldown" and not even allowed to attempt logging in.
+
+ The default cooldown function simply puts the user on cooldown for
+ <login_cooldown_duration> seconds after each failure following the
+ <login_cooldown_after>th (0 to disable).
+
+ Can be overridden to implement more complex backoff strategies, or
+ e.g. wind down or reset the cooldown period as the previous failure
+ recedes into the far past.
+
+ :param int failures: number of recorded failures (since last success)
+ :param previous: timestamp of previous failure
+ :type previous: datetime.datetime
+ :returns: whether the user is currently in cooldown phase (true if cooldown, false if no cooldown and login can continue)
+ :rtype: bool
+ """
+ cfg = self.env['ir.config_parameter'].sudo()
+ min_failures = int(cfg.get_param('base.login_cooldown_after', 5))
+ if min_failures == 0:
+ return True
+
+ delay = int(cfg.get_param('base.login_cooldown_duration', 60))
+ return failures >= min_failures and (datetime.datetime.now() - previous) < datetime.timedelta(seconds=delay)
+
+ def _register_hook(self):
+ if hasattr(self, 'check_credentials'):
+ _logger.warn("The check_credentials method of res.users has been renamed _check_credentials. One of your installed modules defines one, but it will not be called anymore.")
#
# Implied groups
@@ -863,7 +979,6 @@ class GroupsImplied(models.Model):
super(GroupsImplied, group.trans_implied_ids).write(vals)
return res
-
class UsersImplied(models.Model):
_inherit = 'res.users'