From 1ea0f8ddab8be74ba75337fa305c7bfd1eb695a4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 12 Jun 2015 11:57:31 +0530 Subject: [PATCH] Implement workaround for android lack of support for HTTP Auth when downloading files --- src/calibre/srv/auth.py | 19 +++++++++++------- src/calibre/srv/http_response.py | 9 ++++++++- src/calibre/srv/routes.py | 4 +++- src/calibre/srv/tests/auth.py | 33 ++++++++++++++++++++++++++++++-- src/calibre/srv/utils.py | 13 +++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/calibre/srv/auth.py b/src/calibre/srv/auth.py index e4dd6e8cd3..2b6dc9a0fb 100644 --- a/src/calibre/srv/auth.py +++ b/src/calibre/srv/auth.py @@ -7,12 +7,12 @@ __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' import binascii, os, random, struct, base64, httplib -from hashlib import md5, sha1, sha256 +from hashlib import md5, sha256 from itertools import permutations from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse, InvalidCredentials from calibre.srv.http_request import parse_uri -from calibre.srv.utils import parse_http_dict +from calibre.srv.utils import parse_http_dict, encode_path from calibre.utils.monotonic import monotonic MAX_AGE_SECONDS = 3600 @@ -25,9 +25,6 @@ def as_bytestring(x): def md5_hex(s): return md5(as_bytestring(s)).hexdigest().decode('ascii') -def sha1_hex(s): - return sha1(as_bytestring(s)).hexdigest().decode('ascii') - def sha256_hex(s): return sha256(as_bytestring(s)).hexdigest().decode('ascii') @@ -180,6 +177,7 @@ class AuthController(object): 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): self.user_credentials, self.prefer_basic_auth = user_credentials, prefer_basic_auth @@ -202,8 +200,15 @@ class AuthController(object): return pw and self.user_credentials.get(un) == pw def __call__(self, data, endpoint): - # TODO: Implement Android workaround for /get - self.do_http_auth(data, endpoint) + http_auth_needed = not (endpoint.android_workaround and self.validate_android_cookie(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, self.realm, self.secret) + data.outcookie[self.ANDROID_COOKIE]['path'] = encode_path(*data.path) + + def validate_android_cookie(self, cookie): + return cookie and validate_nonce(self.key_order, cookie, self.realm, self.secret) and not is_nonce_stale(cookie, self.max_age_seconds) def do_http_auth(self, data, endpoint): auth = data.inheaders.get('Authorization') diff --git a/src/calibre/srv/http_response.py b/src/calibre/srv/http_response.py index 24390b1434..320916c54b 100644 --- a/src/calibre/srv/http_response.py +++ b/src/calibre/srv/http_response.py @@ -21,7 +21,7 @@ from calibre.srv.http_request import HTTPRequest, read_headers from calibre.srv.sendfile import file_metadata, sendfile_to_socket_async, CannotSendfile, SendfileInterrupted from calibre.srv.utils import ( MultiDict, http_date, HTTP1, HTTP11, socket_errors_socket_closed, - sort_q_values, get_translator_for_lang) + sort_q_values, get_translator_for_lang, Cookie) from calibre.utils.monotonic import monotonic Range = namedtuple('Range', 'start stop size') @@ -194,6 +194,7 @@ class RequestData(object): # {{{ self.remote_addr, self.remote_port = remote_addr, remote_port self.opts = opts self.status_code = httplib.CREATED if self.method == 'POST' else httplib.OK + self.outcookie = Cookie() def generate_static_output(self, name, generator): ans = self.static_cache.get(name) @@ -411,6 +412,12 @@ class HTTPConnection(HTTPRequest): buf = [HTTP11 + (' %d ' % data.status_code) + httplib.responses[data.status_code]] for header, value in sorted(outheaders.iteritems(), key=itemgetter(0)): buf.append('%s: %s' % (header, value)) + for morsel in data.outcookie.itervalues(): + morsel['version'] = '1' + x = morsel.output() + if isinstance(x, bytes): + x = x.decode('ascii') + buf.append(x) buf.append('') self.response_ready(BytesIO(b''.join((x + '\r\n').encode('ascii') for x in buf)), output=output) diff --git a/src/calibre/srv/routes.py b/src/calibre/srv/routes.py index a6c00cf8aa..6fd13021c3 100644 --- a/src/calibre/srv/routes.py +++ b/src/calibre/srv/routes.py @@ -190,7 +190,9 @@ class Router(object): if x: k, v = x.partition('=')[::2] if k: - c[k] = v + # Since we only set simple hex encoded cookies, we dont + # need more sophisticated value parsing + c[k] = v.strip('"') def dispatch(self, data): endpoint_, args = self.find_route(data.path) diff --git a/src/calibre/srv/tests/auth.py b/src/calibre/srv/tests/auth.py index 66c80fd2ef..cc1a659f2b 100644 --- a/src/calibre/srv/tests/auth.py +++ b/src/calibre/srv/tests/auth.py @@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import httplib, base64, urllib2, subprocess, os +import httplib, base64, urllib2, subprocess, os, cookielib from distutils.spawn import find_executable from calibre.srv.tests.base import BaseTest, TestServer @@ -24,7 +24,7 @@ def auth(ctx, data): @endpoint('/android', auth_required=True, android_workaround=True) def android(ctx, data): - return '/android' + return 'android' def router(prefer_basic_auth=False): from calibre.srv.auth import AuthController @@ -171,3 +171,32 @@ class TestAuth(BaseTest): docurl(b'', '--digest', '--user', 'testuser:xtestpw') docurl(b'closed', '--digest', '--user', 'testuser:testpw') # }}} + + def test_android_auth_workaround(self): # {{{ + 'Test authentication workaround for Android' + r = router() + with TestServer(r.dispatch) as server: + r.auth_controller.log = server.log + conn = server.connect() + + # First check that unauth access fails + conn.request('GET', '/android') + r = conn.getresponse() + self.ae(r.status, httplib.UNAUTHORIZED) + + auth_handler = urllib2.HTTPDigestAuthHandler() + url = 'http://localhost:%d%s' % (server.address[1], '/android') + auth_handler.add_password(realm=REALM, uri=url, user='testuser', passwd='testpw') + cj = cookielib.CookieJar() + cookie_handler = urllib2.HTTPCookieProcessor(cj) + r = urllib2.build_opener(auth_handler, cookie_handler).open(url) + self.ae(r.getcode(), httplib.OK) + cookies = tuple(cj) + self.ae(len(cookies), 1) + cookie = cookies[0] + self.assertIn(b':', cookie.value) + self.ae(cookie.path, b'/android') + r = urllib2.build_opener(cookie_handler).open(url) + self.ae(r.getcode(), httplib.OK) + self.ae(r.read(), b'android') + # }}} diff --git a/src/calibre/srv/utils.py b/src/calibre/srv/utils.py index 0490969f05..5c418d1e52 100644 --- a/src/calibre/srv/utils.py +++ b/src/calibre/srv/utils.py @@ -7,12 +7,14 @@ __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' import errno, socket, select +from Cookie import SimpleCookie from contextlib import closing from urlparse import parse_qs import repr as reprlib from email.utils import formatdate from operator import itemgetter from future_builtins import map +from urllib import quote as urlquote from calibre import prints from calibre.constants import iswindows @@ -261,6 +263,17 @@ def get_translator_for_lang(cache, bcp_47_code): cache[bcp_47_code] = ans = get_translator(bcp_47_code) return ans +def encode_path(*components): + 'Encode the path specified as a list of path components using URL encoding' + return '/' + '/'.join(urlquote(x.encode('utf-8'), '').decode('ascii') for x in components) + +class Cookie(SimpleCookie): + + def _BaseCookie__set(self, key, real_value, coded_value): + if not isinstance(key, bytes): + key = key.encode('ascii') # Python 2.x cannot handle unicode keys + return SimpleCookie._BaseCookie__set(self, key, real_value, coded_value) + # Logging {{{ class ServerLog(ThreadSafeLog):