diff --git a/app/frontend.py b/app/frontend.py index c808aa6b..c88acf7b 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -122,7 +122,7 @@ def index(): @app.route("/onvif/device_service", methods=["POST"]) @app.route("/onvif/media_service", methods=["POST"]) def onvif_service(): - response = onvif.onvif_resp(wb.streams) + response = onvif.service_resp(wb.streams) return Response(response, content_type="application/soap+xml") @app.route("/api/sse_status") diff --git a/app/wyzebridge/auth.py b/app/wyzebridge/auth.py index 3bc27bfe..f021633e 100644 --- a/app/wyzebridge/auth.py +++ b/app/wyzebridge/auth.py @@ -1,6 +1,6 @@ import os -from base64 import urlsafe_b64encode -from hashlib import sha256 +from base64 import b64decode, b64encode, urlsafe_b64encode +from hashlib import sha1, sha256 from typing import Optional from werkzeug.security import generate_password_hash @@ -82,6 +82,22 @@ def _update_credentials(cls, email: str, force: bool = False) -> None: cls.api = get_credential("wb_api") or gen_api_key(email) + @classmethod + def auth_onvif(cls, creds: Optional[dict]) -> bool: + if creds and creds.get("username") == "wb": + hashed = onvif_hash(creds["nonce"], creds["created"], cls.api) + return hashed == creds.get("password") + + return cls.enabled is False + + +def onvif_hash(nonce, created, password) -> str: + if not nonce or not created or not password: + return "" + + sha1_hash = sha1(b64decode(nonce) + created.encode() + password.encode()) + return b64encode(sha1_hash.digest()).decode() + def redact_password(password: Optional[str]): return f"{password[0]}{'*' * (len(password) - 1)}" if password else "NOT SET" diff --git a/app/wyzebridge/onvif.py b/app/wyzebridge/onvif.py index a0a99e42..894182d4 100644 --- a/app/wyzebridge/onvif.py +++ b/app/wyzebridge/onvif.py @@ -8,12 +8,15 @@ from flask import request from wyzebridge import config +from wyzebridge.auth import WbAuth from wyzebridge.bridge_utils import env_bool from wyzebridge.logging import logger NAMESPACES = { "s": "http://www.w3.org/2003/05/soap-envelope", "wsdl": "http://www.onvif.org/ver10/media/wsdl", + "wsse": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd", + "wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd", } @@ -72,35 +75,41 @@ def ws_discovery(server): sock.sendto(response.encode("utf-8"), addr) -def parse_action(xml_request): - onvif_path = os.path.basename(request.path) +def parse_request(xml_request): + service = os.path.basename(request.path) try: root = ElementTree.fromstring(xml_request) - namespace = {"s": NAMESPACES["s"]} - body = root.find(".//s:Body", namespace) - if body is not None and len(body): - action_element = body[0] - action = action_element.tag.rsplit("}", 1)[-1] - token = action_element.find(".//wsdl:ProfileToken", NAMESPACES) - profile = token.text if token is not None else None - logger.debug(f"{onvif_path=}, {action=}, {profile=}, {xml_request=}") - return action, profile - except ElementTree.ParseError as e: - logger.error(f"XML parsing error: {e}") - return None, None - - -def onvif_resp(streams): - action, profile_token = parse_action(request.data) + creds = None + if auth := root.find(".//wsse:UsernameToken", NAMESPACES): + creds = { + "username": auth.findtext(".//wsse:Username", None, NAMESPACES), + "password": auth.findtext(".//wsse:Password", None, NAMESPACES), + "nonce": auth.findtext(".//wsse:Nonce", None, NAMESPACES), + "created": auth.findtext(".//wsu:Created", None, NAMESPACES), + } + + if (body := root.find(".//s:Body", NAMESPACES)) and len(body) > 0: + action = body[0].tag.rsplit("}", 1)[-1] + profile = body[0].findtext(".//wsdl:ProfileToken", None, NAMESPACES) + logger.info(f"{service=}, {action=}, {profile=}") + return action, profile, creds + except Exception as ex: + logger.error(f"[ONVIF] error parsing XML request: {ex}") + + return None, None, None + + +def service_resp(streams): + action, profile, creds = parse_request(request.data) if action == "GetProfiles": resp = get_profiles(streams.streams) elif action == "GetVideoSources": resp = get_video_sources() elif action == "GetStreamUri": - resp = get_stream_uri(profile_token) + resp = get_stream_uri(profile) elif action == "GetSnapshotUri": - resp = get_snapshot_uri(profile_token) + resp = get_snapshot_uri(profile) elif action == "GetSystemDateAndTime": resp = get_system_date_and_time() elif action == "GetServices": @@ -128,6 +137,10 @@ def onvif_resp(streams): else: resp = unknown_request() + if not WbAuth.auth_onvif(creds): + logger.error("Onvif auth failed") + resp = unauthorized() + return f""" The requested command is not supported by this device. """ + + +def unauthorized(): + return """ + + soap:Sender + + env:NotAuthorized + + + + Authorization failed: Invalid credentials + + + + Security token is invalid or expired + + + """