From b04eb10682d428efc5a617401d763c9d9a7c0a9b Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 11 Jul 2018 01:05:33 +0200 Subject: [PATCH] Added SESAME_TOTP_SECRET (Issue #100) --- README.md | 2 ++ changelog.txt | 1 + configurator.py | 26 +++++++++++++++++++++----- settings.conf | 1 + 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 82a71b4..63f66c4 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Verify, that the user that is running the configurator is allowed to push withou If set to `true`, directories will be displayed at the top. #### SESAME (string) If set to _somesecretkeynobodycanguess_, you can browse to `https://your.configurator:3218/somesecretkeynobodycanguess` from any IP, and it will be removed from the `BANNED_IPS` list (in case it has been banned before) and added to the `ALLOWED_NETWORKS` list. Once the request has been processed you will automatically be redirected to the configurator. Think of this as dynamically allowing access from untrusted IPs by providing a secret key (_open sesame!_). Keep in mind, that once the IP has been added, you will either have to restart the configurator or manually remove the IP through the _Network status_ to revoke access. +#### SESAME_TOTP_SECRET (string) +Instead of or additionally to the `SESAME` token you may also specify a [Base32](https://en.wikipedia.org/wiki/Base32) encoded string that serves as the token for time based OTP (one time password) IP whitelisting. It works like the regular `SESAME`, but the request path that whitelists your IP changes every 30 seconds. You can add the `SESAME_TOTP_SECRET` to most of the available OTP-Apps (Google Authenticator and alike) and just append the 6-digit number to the URI where your configurator is reachable. For this to work the [pyotp](https://github.com/pyotp/pyotp) module has to be installed. #### VERIFY_HOSTNAME (string) HTTP requests include the hostname to which the request has been made. To improve security you can set this parameter to `yourdomain.example.com`. This will check if the hostname within the request matches the one you are expecting. If it does not match, a `403 Forbidden` response will be sent. As a result attackers that scan your IP address won't be able to connect unless they know the correct hostname. Be careful with this option though, because it prohibits you from accessing the configurator directly via IP. #### ENV_PREFIX (string) diff --git a/changelog.txt b/changelog.txt index 68fb782..88cd4c9 100644 --- a/changelog.txt +++ b/changelog.txt @@ -5,6 +5,7 @@ Version 0.3.0 (2018-) - Notifying if used passwords are insecure (Issue #100) and if SESAME has been used @danielperna84 - CREDENTIALS setting replaced by USERNAME and PASSWORD @danielperna84 - PASSWORD can optionally be provided as SHA256 hash (Issue #100) @danielperna84 +- Added SESAME_TOTP_SECRET for TOTP based IP whitelisting (Issue #100) @danielperna84 Version 0.2.9 (2018-06-22) - Material Icons and HASS-help now open in new tab instead of modal (Issues #85 and #34) @danielperna84 diff --git a/configurator.py b/configurator.py index f0dbde8..dbb667a 100755 --- a/configurator.py +++ b/configurator.py @@ -69,6 +69,9 @@ DIRSFIRST = False # Sesame token. Browse to the configurator URL + /secrettoken to unban your # client IP and add it to the list of allowed IPs. SESAME = None +# Instead of a static SESAME token you may also use a TOTP based token that +# changes every 30 seconds. The value needs to be a base 32 encoded string. +SESAME_TOTP_SECRET = None # Verify the hostname used in the request. Block access if it doesn't match # this value VERIFY_HOSTNAME = None @@ -91,14 +94,16 @@ RELEASEURL = "https://api.github.com/repos/danielperna84/hass-configurator/relea VERSION = "0.3.0" BASEDIR = "." DEV = False +TOTP = None HTTPD = None FAIL2BAN_IPS = {} REPO = False if GIT: try: from git import Repo as REPO - except Exception: + except ImportError: LOG.warning("Unable to import Git module") + INDEX = Template(r""" @@ -3395,7 +3400,7 @@ def load_settings(settingsfile): global LISTENIP, LISTENPORT, BASEPATH, SSL_CERTIFICATE, SSL_KEY, HASS_API, \ HASS_API_PASSWORD, CREDENTIALS, ALLOWED_NETWORKS, BANNED_IPS, BANLIMIT, \ DEV, IGNORE_PATTERN, DIRSFIRST, SESAME, VERIFY_HOSTNAME, ENFORCE_BASEPATH, \ - ENV_PREFIX, NOTIFY_SERVICE, USERNAME, PASSWORD + ENV_PREFIX, NOTIFY_SERVICE, USERNAME, PASSWORD, SESAME_TOTP_SECRET, TOTP settings = {} if settingsfile: try: @@ -3437,6 +3442,7 @@ def load_settings(settingsfile): IGNORE_PATTERN = settings.get("IGNORE_PATTERN", IGNORE_PATTERN) DIRSFIRST = settings.get("DIRSFIRST", DIRSFIRST) SESAME = settings.get("SESAME", SESAME) + SESAME_TOTP_SECRET = settings.get("SESAME_TOTP_SECRET", SESAME_TOTP_SECRET) VERIFY_HOSTNAME = settings.get("VERIFY_HOSTNAME", VERIFY_HOSTNAME) NOTIFY_SERVICE = settings.get("NOTIFY_SERVICE", NOTIFY_SERVICE_DEFAULT) USERNAME = settings.get("USERNAME", USERNAME) @@ -3446,6 +3452,14 @@ def load_settings(settingsfile): PASSWORD = ":".join(CREDENTIALS.split(":")[1:]) if PASSWORD and PASSWORD.startswith("{sha256}"): PASSWORD = PASSWORD.lower() + if SESAME_TOTP_SECRET: + try: + import pyotp + TOTP = pyotp.TOTP(SESAME_TOTP_SECRET) + except ImportError: + LOG.warning("Unable to import pyotp module") + except Exception as err: + LOG.warning("Unable to create TOTP object: %s" % err) def is_safe_path(basedir, path, follow_symlinks=True): if basedir is None: @@ -3588,13 +3602,14 @@ class RequestHandler(BaseHTTPRequestHandler): self.do_BLOCK(403, "Forbidden") return req = urlparse(self.path) - if SESAME: - if req.path.endswith("/%s" % SESAME): + if SESAME or TOTP: + chunk = req.path.split("/")[-1] + if chunk == SESAME or TOTP.verify(chunk): if self.client_address[0] not in ALLOWED_NETWORKS: ALLOWED_NETWORKS.append(self.client_address[0]) if self.client_address[0] in BANNED_IPS: BANNED_IPS.remove(self.client_address[0]) - url = req.path[:req.path.rfind(SESAME)] + url = req.path[:req.path.rfind(chunk)] self.send_response(302) self.send_header('Location', url) self.end_headers() @@ -4590,6 +4605,7 @@ def notify(title="HASS Configurator", "%sservices/%s" % (HASS_API, NOTIFY_SERVICE.replace('.', '/')), data=bytes(json.dumps(data).encode('utf-8')), headers=headers, method='POST') + LOG.info("%s" % data) try: with urllib.request.urlopen(req) as response: message = response.read().decode('utf-8') diff --git a/settings.conf b/settings.conf index df49a93..d3d74c2 100644 --- a/settings.conf +++ b/settings.conf @@ -15,6 +15,7 @@ "IGNORE_PATTERN": [], "DIRSFIRST": false, "SESAME": null, + "SESAME_TOTP_SECRET": null, "VERIFY_HOSTNAME": null, "ENV_PREFIX": "HC_", "NOTIFY_SERVICE": "persistent_notification.create"