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 + seconds after each failure following the + 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'