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>'
|
__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')
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user