%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)