[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
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user