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>' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import binascii, os, random, struct, base64, httplib import binascii, os, random, struct, base64, httplib
from collections import OrderedDict
from hashlib import md5, sha256 from hashlib import md5, sha256
from itertools import permutations from itertools import permutations
from threading import Lock 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.http_request import parse_uri
from calibre.srv.utils import parse_http_dict, encode_path from calibre.srv.utils import parse_http_dict, encode_path
from calibre.utils.monotonic import monotonic from calibre.utils.monotonic import monotonic
@ -20,6 +21,45 @@ MAX_AGE_SECONDS = 3600
nonce_counter, nonce_counter_lock = 0, Lock() 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): def as_bytestring(x):
if not isinstance(x, bytes): if not isinstance(x, bytes):
x = x.encode('utf-8') x = x.encode('utf-8')
@ -195,8 +235,11 @@ class AuthController(object):
''' '''
ANDROID_COOKIE = 'android_workaround' 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.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.log = log
self.secret = binascii.hexlify(os.urandom(random.randint(20, 30))).decode('ascii') self.secret = binascii.hexlify(os.urandom(random.randint(20, 30))).decode('ascii')
self.max_age_seconds = max_age_seconds 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) 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): 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') auth = data.inheaders.get('Authorization')
nonce_is_stale = False nonce_is_stale = False
log_msg = None log_msg = None
@ -239,6 +285,7 @@ class AuthController(object):
data.username = da.username data.username = da.username
return return
log_msg = 'Failed login attempt from: %s' % data.remote_addr log_msg = 'Failed login attempt from: %s' % data.remote_addr
self.ban_list.failed(ban_key)
elif self.prefer_basic_auth and scheme == 'basic': elif self.prefer_basic_auth and scheme == 'basic':
try: try:
un, pw = base64_decode(rest.strip()).partition(':')[::2] un, pw = base64_decode(rest.strip()).partition(':')[::2]
@ -250,6 +297,7 @@ class AuthController(object):
data.username = un data.username = un
return return
log_msg = 'Failed login attempt from: %s' % data.remote_addr log_msg = 'Failed login attempt from: %s' % data.remote_addr
self.ban_list.failed(ban_key)
else: else:
raise HTTPSimpleResponse(httplib.BAD_REQUEST, 'Unsupported authentication method') raise HTTPSimpleResponse(httplib.BAD_REQUEST, 'Unsupported authentication method')

View File

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

View File

@ -163,7 +163,8 @@ class Handler(object):
if opts.auth: if opts.auth:
has_ssl = opts.ssl_certfile is not None and opts.ssl_keyfile is not None 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) 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) self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller)
for module in SRV_MODULES: for module in SRV_MODULES:
module = import_module('calibre.srv.' + module) module = import_module('calibre.srv.' + module)

View File

@ -210,7 +210,7 @@ class RequestData(object): # {{{
username = None username = None
def __init__(self, method, path, query, inheaders, request_body_file, outheaders, response_protocol, 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.method, self.path, self.query, self.inheaders, self.request_body_file, self.outheaders,
self.response_protocol, self.static_cache, self.translator_cache) = ( self.response_protocol, self.static_cache, self.translator_cache) = (
@ -218,6 +218,7 @@ class RequestData(object): # {{{
response_protocol, static_cache, translator_cache response_protocol, static_cache, translator_cache
) )
self.remote_addr, self.remote_port, self.is_local_connection = remote_addr, remote_port, is_local_connection 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.opts = opts
self.status_code = httplib.OK self.status_code = httplib.OK
self.outcookie = Cookie() self.outcookie = Cookie()
@ -432,7 +433,7 @@ class HTTPConnection(HTTPRequest):
self.method, self.path, self.query, inheaders, request_body_file, self.method, self.path, self.query, inheaders, request_body_file,
outheaders, self.response_protocol, self.static_cache, self.opts, outheaders, self.response_protocol, self.static_cache, self.opts,
self.remote_addr, self.remote_port, self.is_local_connection, 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) 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' ' 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".'), ' 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 user-defined metadata fields'),
'ignored_fields', None, 'ignored_fields', None,
_('Comma separated list of user-defined metadata fields that will not be displayed' _('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' __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' __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 from collections import namedtuple
try: try:
from distutils.spawn import find_executable from distutils.spawn import find_executable
@ -41,10 +41,11 @@ def android2(ctx, data):
return 'android2' 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 from calibre.srv.auth import AuthController
return Router(globals().itervalues(), auth_controller=AuthController( return Router(globals().itervalues(), auth_controller=AuthController(
{'testuser':'testpw', '!@#$%^&*()-=_+':'!@#$%^&*()-=_+'}, {'testuser':'testpw', '!@#$%^&*()-=_+':'!@#$%^&*()-=_+'},
ban_time_in_minutes=ban_for, ban_after=ban_after,
prefer_basic_auth=prefer_basic_auth, realm=REALM, max_age_seconds=1)) 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') 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): # {{{ def test_android_auth_workaround(self): # {{{
'Test authentication workaround for Android' 'Test authentication workaround for Android'
r = router() r = router()