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

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

View File

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

View File

@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__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 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')
# }}}

View File

@ -7,12 +7,14 @@ __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
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):