Implement workaround for android lack of support for HTTP Auth when downloading files

This commit is contained in:
Kovid Goyal 2015-06-12 11:57:31 +05:30
parent f6e4eaf375
commit 1ea0f8ddab
5 changed files with 67 additions and 11 deletions

View File

@ -7,12 +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 hashlib import md5, sha1, sha256 from hashlib import md5, sha256
from itertools import permutations from itertools import permutations
from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse, InvalidCredentials from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse, InvalidCredentials
from calibre.srv.http_request import parse_uri 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 from calibre.utils.monotonic import monotonic
MAX_AGE_SECONDS = 3600 MAX_AGE_SECONDS = 3600
@ -25,9 +25,6 @@ def as_bytestring(x):
def md5_hex(s): def md5_hex(s):
return md5(as_bytestring(s)).hexdigest().decode('ascii') return md5(as_bytestring(s)).hexdigest().decode('ascii')
def sha1_hex(s):
return sha1(as_bytestring(s)).hexdigest().decode('ascii')
def sha256_hex(s): def sha256_hex(s):
return sha256(as_bytestring(s)).hexdigest().decode('ascii') 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 not implement the digest auth spec properly (it sends out of order nc
values). 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): 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 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 return pw and self.user_credentials.get(un) == pw
def __call__(self, data, endpoint): def __call__(self, data, endpoint):
# TODO: Implement Android workaround for /get http_auth_needed = not (endpoint.android_workaround and self.validate_android_cookie(data.cookies.get(self.ANDROID_COOKIE)))
self.do_http_auth(data, endpoint) 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): def do_http_auth(self, data, endpoint):
auth = data.inheaders.get('Authorization') auth = data.inheaders.get('Authorization')

View File

@ -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.sendfile import file_metadata, sendfile_to_socket_async, CannotSendfile, SendfileInterrupted
from calibre.srv.utils import ( from calibre.srv.utils import (
MultiDict, http_date, HTTP1, HTTP11, socket_errors_socket_closed, 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 from calibre.utils.monotonic import monotonic
Range = namedtuple('Range', 'start stop size') Range = namedtuple('Range', 'start stop size')
@ -194,6 +194,7 @@ class RequestData(object): # {{{
self.remote_addr, self.remote_port = remote_addr, remote_port self.remote_addr, self.remote_port = remote_addr, remote_port
self.opts = opts self.opts = opts
self.status_code = httplib.CREATED if self.method == 'POST' else httplib.OK self.status_code = httplib.CREATED if self.method == 'POST' else httplib.OK
self.outcookie = Cookie()
def generate_static_output(self, name, generator): def generate_static_output(self, name, generator):
ans = self.static_cache.get(name) ans = self.static_cache.get(name)
@ -411,6 +412,12 @@ class HTTPConnection(HTTPRequest):
buf = [HTTP11 + (' %d ' % data.status_code) + httplib.responses[data.status_code]] buf = [HTTP11 + (' %d ' % data.status_code) + httplib.responses[data.status_code]]
for header, value in sorted(outheaders.iteritems(), key=itemgetter(0)): for header, value in sorted(outheaders.iteritems(), key=itemgetter(0)):
buf.append('%s: %s' % (header, value)) 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('') buf.append('')
self.response_ready(BytesIO(b''.join((x + '\r\n').encode('ascii') for x in buf)), output=output) self.response_ready(BytesIO(b''.join((x + '\r\n').encode('ascii') for x in buf)), output=output)

View File

@ -190,7 +190,9 @@ class Router(object):
if x: if x:
k, v = x.partition('=')[::2] k, v = x.partition('=')[::2]
if k: 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): def dispatch(self, data):
endpoint_, args = self.find_route(data.path) endpoint_, args = self.find_route(data.path)

View File

@ -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 import httplib, base64, urllib2, subprocess, os, cookielib
from distutils.spawn import find_executable from distutils.spawn import find_executable
from calibre.srv.tests.base import BaseTest, TestServer from calibre.srv.tests.base import BaseTest, TestServer
@ -24,7 +24,7 @@ def auth(ctx, data):
@endpoint('/android', auth_required=True, android_workaround=True) @endpoint('/android', auth_required=True, android_workaround=True)
def android(ctx, data): def android(ctx, data):
return '/android' return 'android'
def router(prefer_basic_auth=False): def router(prefer_basic_auth=False):
from calibre.srv.auth import AuthController from calibre.srv.auth import AuthController
@ -171,3 +171,32 @@ class TestAuth(BaseTest):
docurl(b'', '--digest', '--user', 'testuser:xtestpw') docurl(b'', '--digest', '--user', 'testuser:xtestpw')
docurl(b'closed', '--digest', '--user', 'testuser:testpw') 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')
# }}}

View File

@ -7,12 +7,14 @@ __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import errno, socket, select import errno, socket, select
from Cookie import SimpleCookie
from contextlib import closing from contextlib import closing
from urlparse import parse_qs from urlparse import parse_qs
import repr as reprlib import repr as reprlib
from email.utils import formatdate from email.utils import formatdate
from operator import itemgetter from operator import itemgetter
from future_builtins import map from future_builtins import map
from urllib import quote as urlquote
from calibre import prints from calibre import prints
from calibre.constants import iswindows 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) cache[bcp_47_code] = ans = get_translator(bcp_47_code)
return ans 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 {{{ # Logging {{{
class ServerLog(ThreadSafeLog): class ServerLog(ThreadSafeLog):