From 01a8476ff623111fb810744fe1f5d4a0058c4bb1 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 28 Feb 2015 16:00:19 +0200 Subject: [PATCH] added support for streaming authentication (HTTP digest, only supported by newer motion versions) --- src/config.py | 11 +++++- src/handlers.py | 39 ++++++++++++++++---- src/mjpgclient.py | 57 ++++++++++++++++++++++++++-- src/utils.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 13 deletions(-) diff --git a/src/config.py b/src/config.py index 8fac8ff..09a04e0 100644 --- a/src/config.py +++ b/src/config.py @@ -350,6 +350,10 @@ def set_camera(camera_id, camera_config): camera_config['webcam_maxrate'] = camera_config.pop('stream_maxrate') if 'stream_localhost' in camera_config: camera_config['webcam_localhost'] = camera_config.pop('stream_localhost') + if 'stream_auth_method' in camera_config: + camera_config.pop('stream_auth_method') + if 'stream_authentication' in camera_config: + camera_config.pop('stream_authentication') if 'event_gap' in camera_config: camera_config['gap'] = camera_config.pop('event_gap') @@ -529,6 +533,8 @@ def main_dict_to_ui(data): def camera_ui_to_dict(ui): + main_config = get_main() # needed for surveillance password + data = { # device '@name': ui['name'], @@ -558,7 +564,9 @@ def camera_ui_to_dict(ui): '@webcam_resolution': max(1, int(ui['streaming_resolution'])), '@webcam_server_resize': ui['streaming_server_resize'], 'stream_motion': ui['streaming_motion'], - + 'stream_auth_method': 2 if main_config['@normal_password'] else 0, + 'stream_authentication': (main_config['@normal_username'] + ':' + main_config['@normal_password']) if main_config['@normal_password'] else '', + # still images 'output_pictures': False, 'emulate_motion': False, @@ -1361,6 +1369,7 @@ def _set_default_motion_camera(camera_id, data, old_motion=False): data.setdefault('stream_maxrate', 5) data.setdefault('stream_quality', 85) data.setdefault('stream_motion', False) + data.setdefault('stream_auth_method', 0) data.setdefault('@webcam_resolution', 100) data.setdefault('@webcam_server_resize', False) diff --git a/src/handlers.py b/src/handlers.py index 8851bc6..d390fe3 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -289,10 +289,12 @@ class ConfigHandler(BaseHandler): old_main_config = config.get_main() old_admin_credentials = '%s:%s' % (old_main_config.get('@admin_username', ''), old_main_config.get('@admin_password', '')) - + old_normal_credentials = '%s:%s' % (old_main_config.get('@normal_username', ''), old_main_config.get('@normal_password', '')) + main_config = config.main_ui_to_dict(ui_config) main_config.setdefault('thread', old_main_config.get('thread', [])) admin_credentials = '%s:%s' % (main_config.get('@admin_username', ''), main_config.get('@admin_password', '')) + normal_credentials = '%s:%s' % (main_config.get('@normal_username', ''), main_config.get('@normal_password', '')) additional_configs = config.get_additional_structure(camera=False)[1] reboot_config_names = [('@_' + c['name']) for c in additional_configs.values() if c.get('reboot')] @@ -302,18 +304,35 @@ class ConfigHandler(BaseHandler): config.set_main(main_config) reload = False + restart = False if admin_credentials != old_admin_credentials: logging.debug('admin credentials changed, reload needed') reload = True + if normal_credentials != old_normal_credentials: + logging.debug('surveillance credentials changed, all camera configs must be updated') + + for camera_id in config.get_camera_ids(): + local_config = config.get_camera(camera_id) + if not utils.local_camera(local_config): + continue + + # this will update the stream authentication options + ui_config = config.camera_dict_to_ui(local_config) + local_config = config.camera_ui_to_dict(ui_config) + config.set_camera(camera_id, local_config) + + restart = True + if reboot and settings.ENABLE_REBOOT: logging.debug('system settings changed, reboot needed') - - reboot = True + + else: + reboot = False - return {'reload': reload, 'reboot': reboot} + return {'reload': reload, 'reboot': reboot, 'restart': restart} reload = False # indicates that browser should reload the page reboot = [False] # indicates that the server will reboot immediately @@ -368,12 +387,17 @@ class ConfigHandler(BaseHandler): if so_far[0] >= len(ui_config): # finished finish() - - for key, cfg in ui_config.items(): + + # make sure main config is handled first + items = ui_config.items() + items.sort(key=lambda (key, cfg): key != 'main') + + for key, cfg in items: if key == 'main': result = set_main_config(cfg) reload = result['reload'] or reload reboot[0] = result['reboot'] or reboot[0] + restart[0] = result['restart'] or restart[0] check_finished(None, reload) else: @@ -391,6 +415,7 @@ class ConfigHandler(BaseHandler): result = set_main_config(ui_config) reload = result['reload'] reboot[0] = result['reboot'] + restart[0] = result['restart'] @BaseHandler.auth(admin=True) def set_preview(self, camera_id): @@ -1330,6 +1355,4 @@ class LoginHandler(BaseHandler): def post(self): self.set_header('Content-Type', 'text/html') - if not self.current_user: - self.set_status(403) self.finish() diff --git a/src/mjpgclient.py b/src/mjpgclient.py index 2c57ec7..c311aab 100644 --- a/src/mjpgclient.py +++ b/src/mjpgclient.py @@ -34,9 +34,12 @@ class MjpgClient(iostream.IOStream): last_jpg_moment = {} # dictionary of moments of the last received jpeg indexed by camera id last_access = {} # dictionary of access moments indexed by camera id - def __init__(self, camera_id, port): + def __init__(self, camera_id, port, username, password): self._camera_id = camera_id self._port = port + self._username = (username or '').encode('utf8') + self._password = (password or '').encode('utf8') + self._auth_digest_state = {} s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) iostream.IOStream.__init__(self, s) @@ -92,9 +95,51 @@ class MjpgClient(iostream.IOStream): logging.debug('mjpg client for camera %(camera_id)s connected on port %(port)s' % { 'port': self._port, 'camera_id': self._camera_id}) - self.write(b"GET / HTTP/1.0\r\n\r\n") - self._seek_content_length() + self.write('GET / HTTP/1.0\r\n\r\n') + self._seek_http() + + def _seek_http(self): + if self._check_error(): + return + + self.read_until_regex('HTTP/1.\d \d+ ', self._on_http) + + def _on_http(self, data): + if data.endswith('401 '): + self._seek_www_authenticate() + + else: # no authorization required, skip to content length + self._seek_content_length() + + def _seek_www_authenticate(self): + if self._check_error(): + return + + self.read_until('WWW-Authenticate:', self._on_before_www_authenticate) + + def _on_before_www_authenticate(self, data): + if self._check_error(): + return + self.read_until('\r\n', self._on_www_authenticate) + + def _on_www_authenticate(self, data): + if self._check_error(): + return + + m = re.match('Digest\s*realm="([a-zA-Z0-9\-\s]+)",\s*nonce="([a-zA-Z0-9]+)"', data.strip()) + if not m: + logging.error('mjpgclient: unknown authentication header: "%s"' % data) + return self._seek_content_length() + + realm, nonce = m.groups() + self._auth_digest_state['realm'] = realm + self._auth_digest_state['nonce'] = nonce + + auth_header = utils.build_digest_header('GET', '/', self._username, self._password, self._auth_digest_state) + self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) + self._seek_http() + def _seek_content_length(self): if self._check_error(): return @@ -196,7 +241,11 @@ def get_jpg(camera_id): return None port = camera_config['stream_port'] - client = MjpgClient(camera_id, port) + username, password = None, None + if camera_config.get('stream_auth_method') == 2: + username, password = camera_config.get('stream_authentication', ':').split(':') + + client = MjpgClient(camera_id, port, username, password) client.connect() MjpgClient.last_access[camera_id] = datetime.datetime.utcnow() diff --git a/src/utils.py b/src/utils.py index b79b906..1ae4d92 100644 --- a/src/utils.py +++ b/src/utils.py @@ -19,6 +19,7 @@ import datetime import hashlib import logging import os +import time import urllib import urlparse @@ -309,3 +310,96 @@ def compute_signature(method, uri, body, key): uri = urlparse.urlunsplit(parts) return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() + + +def build_digest_header(method, url, username, password, state): + realm = state['realm'] + nonce = state['nonce'] + last_nonce = state.get('last_nonce', '') + nonce_count = state.get('nonce_count', 0) + qop = state.get('qop') + algorithm = state.get('algorithm') + opaque = state.get('opaque') + + if algorithm is None: + _algorithm = 'MD5' + + else: + _algorithm = algorithm.upper() + + if _algorithm == 'MD5' or _algorithm == 'MD5-SESS': + def md5_utf8(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.md5(x).hexdigest() + hash_utf8 = md5_utf8 + + elif _algorithm == 'SHA': + def sha_utf8(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.sha1(x).hexdigest() + hash_utf8 = sha_utf8 + + KD = lambda s, d: hash_utf8("%s:%s" % (s, d)) + + if hash_utf8 is None: + return None + + entdig = None + p_parsed = urlparse.urlparse(url) + path = p_parsed.path + if p_parsed.query: + path += '?' + p_parsed.query + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (method, path) + + HA1 = hash_utf8(A1) + HA2 = hash_utf8(A2) + + if nonce == last_nonce: + nonce_count += 1 + + else: + nonce_count = 1 + + ncvalue = '%08x' % nonce_count + s = str(nonce_count).encode('utf-8') + s += nonce.encode('utf-8') + s += time.ctime().encode('utf-8') + s += os.urandom(8) + + cnonce = (hashlib.sha1(s).hexdigest()[:16]) + if _algorithm == 'MD5-SESS': + HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce)) + + if qop is None: + respdig = KD(HA1, "%s:%s" % (nonce, HA2)) + + elif qop == 'auth' or 'auth' in qop.split(','): + noncebit = "%s:%s:%s:%s:%s" % ( + nonce, ncvalue, cnonce, 'auth', HA2 + ) + respdig = KD(HA1, noncebit) + + else: + return None + + last_nonce = nonce + + base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \ + 'response="%s"' % (username, realm, nonce, path, respdig) + if opaque: + base += ', opaque="%s"' % opaque + if algorithm: + base += ', algorithm="%s"' % algorithm + if entdig: + base += ', digest="%s"' % entdig + if qop: + base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + + state['last_nonce'] = last_nonce + state['nonce_count'] = nonce_count + + return 'Digest %s' % (base) -- 2.39.5