[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:
Xavier Morel
2018-07-26 15:53:26 +02:00
parent 48c058112f
commit a8d868e287
7 changed files with 141 additions and 22 deletions
+2 -3
View File
@@ -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']
+2 -3
View File
@@ -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:
+6 -1
View File
@@ -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,
}
+1 -1
View File
@@ -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
+126 -11
View File
@@ -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'