Server: Add an option to ban IP addresses if there are too many failed login attempts

This commit is contained in:
Kovid Goyal 2017-07-27 12:22:11 +05:30
parent 6cf0462066
commit 234a7b88f9
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 91 additions and 9 deletions

View File

@ -7,11 +7,12 @@ __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import binascii, os, random, struct, base64, httplib
from collections import OrderedDict
from hashlib import md5, sha256
from itertools import permutations
from threading import Lock
from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse
from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse, HTTPForbidden
from calibre.srv.http_request import parse_uri
from calibre.srv.utils import parse_http_dict, encode_path
from calibre.utils.monotonic import monotonic
@ -20,6 +21,45 @@ MAX_AGE_SECONDS = 3600
nonce_counter, nonce_counter_lock = 0, Lock()
class BanList(object):
def __init__(self, ban_time_in_minutes=0, max_failures_before_ban=5):
self.interval = max(0, ban_time_in_minutes) * 60
self.max_failures_before_ban = max(0, max_failures_before_ban)
if not self.interval or not self.max_failures_before_ban:
self.is_banned = lambda *a: False
self.failed = lambda *a: None
else:
self.items = OrderedDict()
self.lock = Lock()
def is_banned(self, key):
with self.lock:
x = self.items.get(key)
if x is None:
return False
previous_fail, fail_count = x
if fail_count < self.max_failures_before_ban:
return False
return monotonic() - previous_fail < self.interval
def failed(self, key):
with self.lock:
x = self.items.pop(key, None)
fail_count = 0 if x is None else x[1]
now = monotonic()
self.items[key] = now, fail_count + 1
remove = []
for old in reversed(self.items):
previous_fail = self.items[old][0]
if now - previous_fail > self.interval:
remove.append(old)
else:
break
for r in remove:
self.items.pop(r, None)
def as_bytestring(x):
if not isinstance(x, bytes):
x = x.encode('utf-8')
@ -195,8 +235,11 @@ class AuthController(object):
'''
ANDROID_COOKIE = 'android_workaround'
def __init__(self, user_credentials=None, prefer_basic_auth=False, realm='calibre', max_age_seconds=MAX_AGE_SECONDS, log=None):
def __init__(self,
user_credentials=None, prefer_basic_auth=False, realm='calibre',
max_age_seconds=MAX_AGE_SECONDS, log=None, ban_time_in_minutes=0, ban_after=5):
self.user_credentials, self.prefer_basic_auth = user_credentials, prefer_basic_auth
self.ban_list = BanList(ban_time_in_minutes=ban_time_in_minutes, max_failures_before_ban=ban_after)
self.log = log
self.secret = binascii.hexlify(os.urandom(random.randint(20, 30))).decode('ascii')
self.max_age_seconds = max_age_seconds
@ -221,6 +264,9 @@ class AuthController(object):
return cookie and validate_nonce(self.key_order, cookie, path, self.secret) and not is_nonce_stale(cookie, self.max_age_seconds)
def do_http_auth(self, data, endpoint):
ban_key = data.remote_addr, data.forwarded_for
if self.ban_list.is_banned(ban_key):
raise HTTPForbidden('Too many login attempts', log='Too many login attempts from: %s' % (ban_key if data.forwarded_for else data.remote_addr))
auth = data.inheaders.get('Authorization')
nonce_is_stale = False
log_msg = None
@ -239,6 +285,7 @@ class AuthController(object):
data.username = da.username
return
log_msg = 'Failed login attempt from: %s' % data.remote_addr
self.ban_list.failed(ban_key)
elif self.prefer_basic_auth and scheme == 'basic':
try:
un, pw = base64_decode(rest.strip()).partition(':')[::2]
@ -250,6 +297,7 @@ class AuthController(object):
data.username = un
return
log_msg = 'Failed login attempt from: %s' % data.remote_addr
self.ban_list.failed(ban_key)
else:
raise HTTPSimpleResponse(httplib.BAD_REQUEST, 'Unsupported authentication method')

View File

@ -54,8 +54,8 @@ class HTTPBadRequest(HTTPSimpleResponse):
class HTTPForbidden(HTTPSimpleResponse):
def __init__(self, http_message='', close_connection=True):
HTTPSimpleResponse.__init__(self, httplib.FORBIDDEN, http_message, close_connection)
def __init__(self, http_message='', close_connection=True, log=None):
HTTPSimpleResponse.__init__(self, httplib.FORBIDDEN, http_message, close_connection, log=log)
class BookNotFound(HTTPNotFound):

View File

@ -163,7 +163,8 @@ class Handler(object):
if opts.auth:
has_ssl = opts.ssl_certfile is not None and opts.ssl_keyfile is not None
prefer_basic_auth = {'auto':has_ssl, 'basic':True}.get(opts.auth_mode, False)
self.auth_controller = AuthController(user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth)
self.auth_controller = AuthController(
user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth, ban_time_in_minutes=opts.ban_for, ban_after=opts.ban_after)
self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller)
for module in SRV_MODULES:
module = import_module('calibre.srv.' + module)

View File

@ -210,7 +210,7 @@ class RequestData(object): # {{{
username = None
def __init__(self, method, path, query, inheaders, request_body_file, outheaders, response_protocol,
static_cache, opts, remote_addr, remote_port, is_local_connection, translator_cache, tdir):
static_cache, opts, remote_addr, remote_port, is_local_connection, translator_cache, tdir, forwarded_for):
(self.method, self.path, self.query, self.inheaders, self.request_body_file, self.outheaders,
self.response_protocol, self.static_cache, self.translator_cache) = (
@ -218,6 +218,7 @@ class RequestData(object): # {{{
response_protocol, static_cache, translator_cache
)
self.remote_addr, self.remote_port, self.is_local_connection = remote_addr, remote_port, is_local_connection
self.forwarded_for = forwarded_for
self.opts = opts
self.status_code = httplib.OK
self.outcookie = Cookie()
@ -432,7 +433,7 @@ class HTTPConnection(HTTPRequest):
self.method, self.path, self.query, inheaders, request_body_file,
outheaders, self.response_protocol, self.static_cache, self.opts,
self.remote_addr, self.remote_port, self.is_local_connection,
self.translator_cache, self.tdir
self.translator_cache, self.tdir, self.forwarded_for
)
self.queue_job(self.run_request_handler, data)

View File

@ -154,6 +154,14 @@ raw_options = (
' putting this server behind an SSL proxy. Otherwise, leave it as "auto", which'
' will use "basic" if SSL is configured otherwise it will use "digest".'),
_('Ban IP addresses that have repeated login failures'), 'ban_for', 0,
_('Temporarily bans access for IP addresses that have repeated login failures for the'
' specified number of minutes. Useful to prevent attempts at guessing passwords. If'
' set to zero, no banning is done.'),
_('Number of login failures for ban'), 'ban_after', 5,
_('The number of login failures after which an IP address is banned'),
_('Ignored user-defined metadata fields'),
'ignored_fields', None,
_('Comma separated list of user-defined metadata fields that will not be displayed'

View File

@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import httplib, base64, urllib2, subprocess, os, cookielib
import httplib, base64, urllib2, subprocess, os, cookielib, time
from collections import namedtuple
try:
from distutils.spawn import find_executable
@ -41,10 +41,11 @@ def android2(ctx, data):
return 'android2'
def router(prefer_basic_auth=False):
def router(prefer_basic_auth=False, ban_for=0, ban_after=5):
from calibre.srv.auth import AuthController
return Router(globals().itervalues(), auth_controller=AuthController(
{'testuser':'testpw', '!@#$%^&*()-=_+':'!@#$%^&*()-=_+'},
ban_time_in_minutes=ban_for, ban_after=ban_after,
prefer_basic_auth=prefer_basic_auth, realm=REALM, max_age_seconds=1))
@ -240,6 +241,29 @@ class TestAuth(BaseTest):
docurl(b'closed', '--digest', '--user', 'testuser:testpw')
# }}}
def test_fail_ban(self): # {{{
ban_for = 0.5/60.0
r = router(prefer_basic_auth=True, ban_for=ban_for, ban_after=2)
with TestServer(r.dispatch) as server:
r.auth_controller.log = server.log
conn = server.connect()
def request(un='testuser', pw='testpw'):
conn.request('GET', '/closed', headers={'Authorization': b'Basic ' + base64.standard_b64encode(bytes('%s:%s' % (un, pw)))})
r = conn.getresponse()
return r.status, r.read()
warnings = []
server.loop.log.warn = lambda *args, **kwargs: warnings.append(' '.join(args))
self.ae((httplib.OK, b'closed'), request())
self.ae((httplib.UNAUTHORIZED, b''), request('x', 'y'))
self.ae((httplib.UNAUTHORIZED, b''), request('x', 'y'))
self.ae(httplib.FORBIDDEN, request('x', 'y')[0])
self.ae(httplib.FORBIDDEN, request()[0])
time.sleep(ban_for * 60 + 0.01)
self.ae((httplib.OK, b'closed'), request())
# }}}
def test_android_auth_workaround(self): # {{{
'Test authentication workaround for Android'
r = router()