mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Server: Add an option to ban IP addresses if there are too many failed login attempts
This commit is contained in:
parent
6cf0462066
commit
234a7b88f9
@ -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')
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user