%PDF- %PDF-
| Direktori : /lib/calibre/calibre/srv/ |
| Current File : //lib/calibre/calibre/srv/auth.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import os, random, struct
from collections import OrderedDict
from hashlib import md5, sha256
from itertools import permutations
from threading import Lock
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
from polyglot import http_client
from polyglot.binary import from_base64_unicode, from_hex_bytes, as_hex_unicode
MAX_AGE_SECONDS = 3600
nonce_counter, nonce_counter_lock = 0, Lock()
class BanList:
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')
return x
def as_unicodestring(x):
if isinstance(x, bytes):
x = x.decode('utf-8')
return x
def md5_hex(s):
return as_unicodestring(md5(as_bytestring(s)).hexdigest())
def sha256_hex(s):
return as_unicodestring(sha256(as_bytestring(s)).hexdigest())
def base64_decode(s):
return from_base64_unicode(s)
def synthesize_nonce(key_order, realm, secret, timestamp=None):
'''
Create a nonce. Can be used for either digest or cookie based auth.
The nonce is of the form timestamp:hash with hash being a hash of the
timestamp, server secret and realm. This allows the timestamp to be
validated and stale nonce's to be rejected.
'''
if timestamp is None:
global nonce_counter
with nonce_counter_lock:
nonce_counter = (nonce_counter + 1) % 65535
# The resolution of monotonic() on windows is very low (10s of
# milliseconds) so to ensure nonce values are not re-used, we have a
# global counter
timestamp = as_hex_unicode(struct.pack(b'!dH', float(monotonic()), nonce_counter))
h = sha256_hex(key_order.format(timestamp, realm, secret))
nonce = ':'.join((timestamp, h))
return nonce
def validate_nonce(key_order, nonce, realm, secret):
timestamp, hashpart = nonce.partition(':')[::2]
s_nonce = synthesize_nonce(key_order, realm, secret, timestamp)
return s_nonce == nonce
def is_nonce_stale(nonce, max_age_seconds=MAX_AGE_SECONDS):
try:
timestamp = struct.unpack(b'!dH', from_hex_bytes(as_bytestring(nonce.partition(':')[0])))[0]
return timestamp + max_age_seconds < monotonic()
except Exception:
pass
return True
class DigestAuth: # {{{
valid_algorithms = {'MD5', 'MD5-SESS'}
valid_qops = {'auth', 'auth-int'}
def __init__(self, header_val):
data = parse_http_dict(header_val)
self.realm = data.get('realm')
self.username = data.get('username')
self.nonce = data.get('nonce')
self.uri = data.get('uri')
self.method = data.get('method')
self.response = data.get('response')
self.algorithm = data.get('algorithm', 'MD5').upper()
self.cnonce = data.get('cnonce')
self.opaque = data.get('opaque')
self.qop = data.get('qop', '').lower()
self.nonce_count = data.get('nc')
if self.algorithm not in self.valid_algorithms:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'Unsupported digest algorithm')
if not (self.username and self.realm and self.nonce and self.uri and self.response):
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'Digest algorithm required fields missing')
if self.qop:
if self.qop not in self.valid_qops:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'Unsupported digest qop')
if not (self.cnonce and self.nonce_count):
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'qop present, but cnonce and nonce_count absent')
else:
if self.cnonce or self.nonce_count:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'qop missing')
def H(self, val):
return md5_hex(val)
def H_A2(self, data):
"""Returns the H(A2) string. See :rfc:`2617` section 3.2.2.3."""
# RFC 2617 3.2.2.3
# If the "qop" directive's value is "auth" or is unspecified,
# then A2 is:
# A2 = method ":" digest-uri-value
#
# If the "qop" value is "auth-int", then A2 is:
# A2 = method ":" digest-uri-value ":" H(entity-body)
if self.qop == "auth-int":
a2 = f"{data.method}:{self.uri}:{self.H(data.peek())}"
else:
a2 = f'{data.method}:{self.uri}'
return self.H(a2)
def request_digest(self, pw, data):
ha1 = self.H(':'.join((self.username, self.realm, pw)))
ha2 = self.H_A2(data)
# Request-Digest -- RFC 2617 3.2.2.1
if self.qop:
req = "{}:{}:{}:{}:{}".format(
self.nonce, self.nonce_count, self.cnonce, self.qop, ha2)
else:
req = f"{self.nonce}:{ha2}"
# RFC 2617 3.2.2.2
#
# If the "algorithm" directive's value is "MD5" or is unspecified,
# then A1 is:
# A1 = unq(username-value) ":" unq(realm-value) ":" passwd
#
# If the "algorithm" directive's value is "MD5-sess", then A1 is
# calculated only once - on the first request by the client following
# receipt of a WWW-Authenticate challenge from the server.
# A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd )
# ":" unq(nonce-value) ":" unq(cnonce-value)
if self.algorithm == 'MD5-SESS':
ha1 = self.H(f'{ha1}:{self.nonce}:{self.cnonce}')
return self.H(f'{ha1}:{req}')
def validate_request(self, pw, data, log=None):
# We should also be checking for replay attacks by using nonce_count,
# however, various HTTP clients, most prominently Firefox dont
# implement nonce-counts correctly, so we cannot do the check.
# https://bugzil.la/114451
path = parse_uri(self.uri.encode('utf-8'))[1]
if path != data.path:
if log is not None:
log.warn('Authorization URI mismatch: {} != {} from client: {}'.format(
data.path, path, data.remote_addr))
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'The uri in the Request Line and the Authorization header do not match')
return self.response is not None and data.path == path and self.request_digest(pw, data) == self.response
# }}}
class AuthController:
'''
Implement Basic/Digest authentication for the Content server. Android browsers
cannot handle HTTP AUTH when downloading files, as the download is handed
off to a separate process. So we use a cookie based authentication scheme
for some endpoints (/get) to allow downloads to work on android. Apparently,
cookies are passed to the download process. The cookie expires after
MAX_AGE_SECONDS.
The android browser appears to send a GET request to the server and only if
that request succeeds is the download handed off to the download process.
We could reduce MAX_AGE_SECONDS, but we leave it high as the download
process might have downloads queued and therefore not start the download
immediately.
Note that this makes the server vulnerable to session-hijacking (i.e. some
one can sniff the traffic and create their own requests to /get with the
appropriate cookie, for an hour). The fix is to use https, but since this
is usually run as a private server, that cannot be done. If you care about
this vulnerability, run the server behind a reverse proxy that uses HTTPS.
Also, note that digest auth is itself vulnerable to partial session
hijacking, since we have to ignore repeated nc values, because Firefox does
not implement the digest auth spec properly (it sends out of order nc
values).
'''
ANDROID_COOKIE = 'android_workaround'
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 = as_hex_unicode(os.urandom(random.randint(20, 30)))
self.max_age_seconds = max_age_seconds
self.key_order = '{%d}:{%d}:{%d}' % random.choice(tuple(permutations((0,1,2))))
self.realm = realm
if '"' in realm:
raise ValueError('Double-quotes are not allowed in the authentication realm')
def check(self, un, pw):
return pw and self.user_credentials.get(un) == pw
def __call__(self, data, endpoint):
path = encode_path(*data.path)
http_auth_needed = not (endpoint.android_workaround and self.validate_android_cookie(path, data.cookies.get(self.ANDROID_COOKIE)))
if http_auth_needed:
self.do_http_auth(data, endpoint)
if endpoint.android_workaround:
data.outcookie[self.ANDROID_COOKIE] = synthesize_nonce(self.key_order, path, self.secret)
data.outcookie[self.ANDROID_COOKIE]['path'] = path
def validate_android_cookie(self, path, cookie):
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
data.username = None
if auth:
scheme, rest = auth.partition(' ')[::2]
scheme = scheme.lower()
if scheme == 'digest':
da = DigestAuth(rest.strip())
if validate_nonce(self.key_order, da.nonce, self.realm, self.secret):
pw = self.user_credentials.get(da.username)
if pw and da.validate_request(pw, data, self.log):
nonce_is_stale = is_nonce_stale(da.nonce, self.max_age_seconds)
if not nonce_is_stale:
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]
except ValueError:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'The username or password contained non-UTF8 encoded characters')
if not un or not pw:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'The username or password was empty')
if self.check(un, pw):
data.username = un
return
log_msg = 'Failed login attempt from: %s' % data.remote_addr
self.ban_list.failed(ban_key)
else:
raise HTTPSimpleResponse(http_client.BAD_REQUEST, 'Unsupported authentication method')
if self.prefer_basic_auth:
raise HTTPAuthRequired('Basic realm="%s"' % self.realm, log=log_msg)
s = 'Digest realm="{}", nonce="{}", algorithm="MD5", qop="auth"'.format(
self.realm, synthesize_nonce(self.key_order, self.realm, self.secret))
if nonce_is_stale:
s += ', stale="true"'
raise HTTPAuthRequired(s, log=log_msg)