Exemple #1
0
class AuthenticateEndpoints(object):

    def __init__(self, ssl_private_key):

        self._ssl_private_key = ssl_private_key
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            FileReloader("web-users.json", self.reload_userdict),
            get_home()
        )
        self._observer.start()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for authentication.

        Tuple should have the following:

            - regular expression for calling the endpoint
            - 'callable' keyword specifying that a method is being specified
            - the method that should be used to call when the regular expression matches

        code:

            return [
                (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new)
            ]

        :return:
        """
        return [
            (re.compile('^/authenticate'), 'callable', self.get_auth_token)
        ]

    def get_auth_token(self, env, data):
        """
        Creates an authentication token to be returned to the caller.  The
        response will be a text/plain encoded user

        :param env:
        :param data:
        :return:
        """
        if env.get('REQUEST_METHOD') != 'POST':
            _log.warning("Authentication must use POST request.")
            return Response('', status='401 Unauthorized')

        assert len(self._userdict) > 0, "No users in user dictionary, set the master password first!"

        if not isinstance(data, dict):
            _log.debug("data is not a dict, decoding")
            decoded = dict((k, v if len(v) > 1 else v[0])
                           for k, v in urlparse.parse_qs(data).iteritems())

            username = decoded.get('username')
            password = decoded.get('password')

        else:
            username = data.get('username')
            password = data.get('password')

        _log.debug("Username is: {}".format(username))

        error = ""
        if username is None:
            error += "Invalid username passed"
        if not password:
            error += "Invalid password passed"

        if error:
            _log.error("Invalid parameters passed: {}".format(error))
            return Response(error, status='401')

        user = self.__get_user(username, password)
        if user is None:
            _log.error("No matching user for passed username: {}".format(username))
            return Response('', status='401')

        encoded = jwt.encode(user, self._ssl_private_key, algorithm='RS256').encode('utf-8')

        return Response(encoded, '200 OK', content_type='text/plain')

    def __get_user(self, username, password):
        """
        Retrieve user from the user store based upon username/password

        The hashed_password will not be returned with the value in the user
        object.

        If there is not a username/password that match return None.

        :param username:
        :param password:
        :return:
        """
        user = self._userdict.get(username)
        if user is not None:
            hashed_pass = user.get('hashed_password')
            if hashed_pass and argon2.verify(password, hashed_pass):
                usr_cpy = user.copy()
                del usr_cpy['hashed_password']
                return usr_cpy
        return None
class AuthenticateEndpoints(object):
    def __init__(self,
                 tls_private_key=None,
                 tls_public_key=None,
                 web_secret_key=None):

        self.refresh_token_timeout = 240  # minutes before token expires. TODO: Should this be a setting somewhere?
        self.access_token_timeout = 15  # minutes before token expires. TODO: Should this be a setting somewhere?
        self._tls_private_key = tls_private_key
        self._tls_public_key = tls_public_key
        self._web_secret_key = web_secret_key
        if self._tls_private_key is None and self._web_secret_key is None:
            raise ValueError(
                "Must have either ssl_private_key or web_secret_key specified!"
            )
        if self._tls_private_key is not None and self._web_secret_key is not None:
            raise ValueError(
                "Must use either ssl_private_key or web_secret_key not both!")
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            VolttronHomeFileReloader("web-users.json", self.reload_userdict),
            get_home())
        self._observer.start()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for authentication.

        Tuple should have the following:

            - regular expression for calling the endpoint
            - 'callable' keyword specifying that a method is being specified
            - the method that should be used to call when the regular expression matches

        code:

            return [
                (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new)
            ]

        :return:
        """
        return [(re.compile('^/authenticate'), 'callable',
                 self.handle_authenticate)]

    def handle_authenticate(self, env, data):
        """
        Callback for /authenticate endpoint.

        Routes request based on HTTP method and returns a text/plain encoded token or error.

        :param env:
        :param data:
        :return: Response
        """
        method = env.get('REQUEST_METHOD')
        if method == 'POST':
            response = self.get_auth_tokens(env, data)
        elif method == 'PUT':
            response = self.renew_auth_token(env, data)
        elif method == 'DELETE':
            response = self.revoke_auth_token(env, data)
        else:
            error = f"/authenticate endpoint accepts only POST, PUT, or DELETE methods. Received: {method}"
            _log.warning(error)
            return Response(error,
                            status='405 Method Not Allowed',
                            content_type='text/plain')
        return response

    def get_auth_tokens(self, env, data):
        """
        Creates an authentication refresh and acccss tokens to be returned to the caller.  The
        response will be a text/plain encoded user.  Data should contain:
        {
            "username": "******",
            "password": "******"
        }
        :param env:
        :param data:
        :return:
        """

        assert len(
            self._userdict
        ) > 0, "No users in user dictionary, set the administrator password first!"

        if not isinstance(data, dict):
            _log.debug("data is not a dict, decoding")
            decoded = dict((k, v if len(v) > 1 else v[0])
                           for k, v in parse_qs(data).items())

            username = decoded.get('username')
            password = decoded.get('password')

        else:
            username = data.get('username')
            password = data.get('password')

        _log.debug("Username is: {}".format(username))

        error = ""
        if username is None:
            error += "Invalid username passed"
        if not password:
            error += "Invalid password passed"

        if error:
            _log.error("Invalid parameters passed: {}".format(error))
            return Response(error, status='401')

        user = self.__get_user(username, password)
        if user is None:
            _log.error(
                "No matching user for passed username: {}".format(username))
            return Response('', status='401')
        access_token, refresh_token = self._get_tokens(user)
        response = Response(json.dumps({
            "refresh_token": refresh_token,
            "access_token": access_token
        }),
                            content_type="application/json")
        return response

    def _get_tokens(self, claims):
        now = datetime.utcnow()
        claims['iat'] = now
        claims['nbf'] = now
        claims['exp'] = now + timedelta(minutes=self.access_token_timeout)
        claims['grant_type'] = 'access_token'
        algorithm = 'RS256' if self._tls_private_key is not None else 'HS256'
        encode_key = self._tls_private_key if algorithm == 'RS256' else self._web_secret_key
        access_token = jwt.encode(claims, encode_key, algorithm=algorithm)
        claims['exp'] = now + timedelta(minutes=self.refresh_token_timeout)
        claims['grant_type'] = 'refresh_token'
        refresh_token = jwt.encode(claims, encode_key, algorithm=algorithm)
        return access_token.decode('utf-8'), refresh_token.decode('utf8')

    def renew_auth_token(self, env, data):
        """
        Creates a new authentication access token to be returned to the caller.  The
        response will be a text/plain encoded user.  Request should contain:
            • Content Type: application/json
            • Authorization: BEARER <jwt_refresh_token>
            • Body (optional):
                {
                "current_access_token": "<jwt_access_token>"
                }

        :param env:
        :param data:
        :return:
        """
        current_access_token = data.get('current_access_token')
        from volttron.platform.web import get_bearer, get_user_claim_from_bearer, NotAuthorized
        try:
            current_refresh_token = get_bearer(env)
            claims = get_user_claim_from_bearer(
                current_refresh_token,
                web_secret_key=self._web_secret_key,
                tls_public_key=self._tls_public_key)
        except NotAuthorized:
            _log.error("Unauthorized user attempted to connect to {}".format(
                env.get('PATH_INFO')))
            return Response('Unauthorized User', status="401 Unauthorized")

        except jwt.ExpiredSignatureError:
            _log.error(
                "User attempted to connect to {} with an expired signature".
                format(env.get('PATH_INFO')))
            return Response('Unauthorized User', status="401 Unauthorized")

        if claims.get('grant_type') != 'refresh_token' or not claims.get(
                'groups'):
            return Response('Invalid refresh token.',
                            status="401 Unauthorized")
        else:
            # TODO: Consider blacklisting and reissuing refresh tokens also when used.
            new_access_token, _ = self._get_tokens(claims)
            if current_access_token:
                pass  # TODO: keep current subscriptions? blacklist old token?
            return Response(json.dumps({"access_token": new_access_token}),
                            content_type="application/json")

    def revoke_auth_token(self, env, data):
        # TODO: Blacklist old token? Immediately close websockets?
        return Response('DELETE /authenticate is not yet implemented',
                        status='501 Not Implemented',
                        content_type='text/plain')

    def __get_user(self, username, password):
        """
        Retrieve user from the user store based upon username/password

        The hashed_password will not be returned with the value in the user
        object.

        If there is not a username/password that match return None.

        :param username:
        :param password:
        :return:
        """
        user = self._userdict.get(username)
        if user is not None:
            hashed_pass = user.get('hashed_password')
            if hashed_pass and argon2.verify(password, hashed_pass):
                usr_cpy = user.copy()
                del usr_cpy['hashed_password']
                return usr_cpy
        return None
Exemple #3
0
class AdminEndpoints(object):
    def __init__(self, rmq_mgmt, ssl_public_key):

        self._rmq_mgmt = rmq_mgmt
        self._ssl_public_key = ssl_public_key
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            FileReloader("web-users.json", self.reload_userdict), get_home())
        self._observer.start()
        self._certs = Certs()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for the adminstration endpoints
        available in it.

        :return:
        """
        return [(re.compile('^/admin.*'), 'callable', self.admin)]

    def admin(self, env, data):
        if len(self._userdict) == 0:
            if env.get('REQUEST_METHOD') == 'POST':
                decoded = dict((k, v if len(v) > 1 else v[0])
                               for k, v in urlparse.parse_qs(data).iteritems())
                username = decoded.get('username')
                pass1 = decoded.get('password1')
                pass2 = decoded.get('password2')
                if pass1 == pass2 and pass1 is not None:
                    _log.debug("Setting master password")
                    self.add_user(username, pass1, groups=['admin'])
                    return Response('',
                                    status='302',
                                    headers={'Location': '/admin/login.html'})

            template = template_env(env).get_template('first.html')
            return Response(template.render())

        if 'login.html' in env.get('PATH_INFO') or '/admin/' == env.get(
                'PATH_INFO'):
            template = template_env(env).get_template('login.html')
            return Response(template.render())

        return self.verify_and_dispatch(env, data)

    def verify_and_dispatch(self, env, data):
        """ Verify that the user is an admin and dispatch

        :param env: web environment
        :param data: data associated with a web form or json/xml request data
        :return: Response object.
        """
        from volttron.platform.web import get_user_claims, NotAuthorized
        try:
            claims = get_user_claims(env)
        except NotAuthorized:
            _log.error("Unauthorized user attempted to connect to {}".format(
                env.get('PATH_INFO')))
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        # Make sure we have only admins for viewing this.
        if 'admin' not in claims.get('groups'):
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        # Make sure we have only admins for viewing this.
        if 'admin' not in claims.get('groups'):
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        path_info = env.get('PATH_INFO')
        if path_info.startswith('/admin/api/'):
            return self.__api_endpoint(path_info[len('/admin/api/'):], data)

        if path_info.endswith('html'):
            page = path_info.split('/')[-1]
            try:
                template = template_env(env).get_template(page)
            except TemplateNotFound:
                return Response("<h1>404 Not Found</h1>",
                                status="404 Not Found")

            if page == 'list_certs.html':
                html = template.render(
                    certs=self._certs.get_all_cert_subjects())
            elif page == 'pending_csrs.html':
                html = template.render(
                    csrs=self._certs.get_pending_csr_requests())
            else:
                # A template with no params.
                html = template.render()

            return Response(html)

        template = template_env(env).get_template('index.html')
        resp = template.render()
        return Response(resp)

    def __api_endpoint(self, endpoint, data):
        _log.debug("Doing admin endpoint {}".format(endpoint))
        if endpoint == 'certs':
            response = self.__cert_list_api()
        elif endpoint == 'pending_csrs':
            response = self.__pending_csrs_api()
        elif endpoint.startswith('approve_csr/'):
            response = self.__approve_csr_api(endpoint.split('/')[1])
        elif endpoint.startswith('deny_csr/'):
            response = self.__deny_csr_api(endpoint.split('/')[1])
        elif endpoint.startswith('delete_csr/'):
            response = self.__delete_csr_api(endpoint.split('/')[1])
        else:
            response = Response(
                '{"status": "Unknown endpoint {}"}'.format(endpoint),
                content_type="application/json")
        return response

    def __approve_csr_api(self, common_name):
        try:
            _log.debug("Creating cert and permissions for user: {}".format(
                common_name))
            self._certs.approve_csr(common_name)
            permissions = self._rmq_mgmt.get_default_permissions(common_name)
            self._rmq_mgmt.create_user_with_permissions(
                common_name, permissions, True)
            data = dict(status=self._certs.get_csr_status(common_name),
                        cert=self._certs.get_cert_from_csr(common_name))
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __deny_csr_api(self, common_name):
        try:
            self._certs.deny_csr(common_name)
            data = dict(status="DENIED",
                        message="The administrator has denied the request")
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __delete_csr_api(self, common_name):
        try:
            self._certs.delete_csr(common_name)
            data = dict(status="DELETED",
                        message="The administrator has denied the request")
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __pending_csrs_api(self):
        csrs = [c for c in self._certs.get_pending_csr_requests()]
        return Response(json.dumps(csrs), content_type="application/json")

    def __cert_list_api(self):

        subjects = [
            dict(common_name=x.common_name)
            for x in self._certs.get_all_cert_subjects()
        ]
        return Response(json.dumps(subjects), content_type="application/json")

    def add_user(self, username, unencrypted_pw, groups=[], overwrite=False):
        if self._userdict.get(username):
            raise ValueError("Already exists!")
        if groups is None:
            groups = []
        hashed_pass = argon2.hash(unencrypted_pw)
        self._userdict[username] = dict(hashed_password=hashed_pass,
                                        groups=groups)
        self._userdict.async_sync()
Exemple #4
0
class Interface(BasicRevert, BaseInterface):
    """
    Interface implementation for wrapping around the Ecobee thermostat API
    """

    def __init__(self, **kwargs):
        super(Interface, self).__init__(**kwargs)
        # Configuration value defaults
        self.config_dict = {}
        self.api_key = ""
        self.ecobee_id = -1
        # which agent is being used as the caching agent
        self.cache = None
        # Authorization tokens
        self.refresh_token = None
        self.access_token = None
        self.authorization_code = None
        self.authorization_stage = "UNAUTHORIZED"
        # Config path for storing Ecobee auth information in config store, not user facing
        self.auth_config_path = ""
        # Un-initialized data response from Driver Cache agent
        self.thermostat_data = None
        # Un-initialized greenlet for querying cache agent
        self.poll_greenlet_thermostats = None

    def configure(self, config_dict, registry_config_str):
        """
        Interface configuration callback
        :param config_dict: Driver configuration dictionary
        :param registry_config_str: Driver registry configuration dictionary
        """
        self.config_dict.update(config_dict)
        self.api_key = self.config_dict.get("API_KEY")
        self.ecobee_id = self.config_dict.get('DEVICE_ID')
        if not isinstance(self.ecobee_id, int):
            try:
                self.ecobee_id = int(self.ecobee_id)
            except ValueError:
                raise ValueError(
                    f"Ecobee driver requires Ecobee device identifier as int, got: {self.ecobee_id}")
        self.cache = PersistentDict("ecobee_" + str(self.ecobee_id) + ".json", format='json')
        self.auth_config_path = AUTH_CONFIG_PATH.format(self.ecobee_id)
        self.parse_config(registry_config_str)

        # Fetch any stored configuration values to reuse
        self.authorization_stage = "UNAUTHORIZED"
        stored_auth_config = self.get_auth_config_from_store()
        # Do some minimal checks on auth
        if stored_auth_config:
            if stored_auth_config.get("AUTH_CODE"):
                self.authorization_code = stored_auth_config.get("AUTH_CODE")
                self.authorization_stage = "REQUEST_TOKENS"
                if stored_auth_config.get("ACCESS_TOKEN") and stored_auth_config.get("REFRESH_TOKEN"):
                    self.access_token = stored_auth_config.get("ACCESS_TOKEN")
                    self.refresh_token = stored_auth_config.get("REFRESH_TOKEN")
                    try:
                        self.get_thermostat_data()
                        self.authorization_stage = "AUTHORIZED"
                    except HTTPError:
                        _log.warning("Ecobee request response contained HTTP Error, authorization code may be expired. "
                                     "Requesting new authorization code from Ecobee api")
                        self.authorization_stage = "UNAUTHORIZED"
        if self.authorization_stage != "AUTHORIZED":
            # if this fails, our attempt to obtain new auth code and tokens was unsuccessful and the driver is in an
            # error state
            self.update_authorization()
            self.get_thermostat_data()

        if not self.poll_greenlet_thermostats:
            self.poll_greenlet_thermostats = self.core.periodic(180, self.get_thermostat_data)
        _log.debug("Ecobee configuration complete.")

    def parse_config(self, config_dict):
        """
        Parse driver registry configuration and create device registers
        :param config_dict: Registry configuration in dictionary representation
        """
        first_hold = True
        _log.debug("Parsing Ecobee registry configuration.")
        if not config_dict:
            return
        # Parse configuration file for registry parameters, then add new register to the interface
        for index, regDef in enumerate(config_dict):
            point_name = regDef.get("Point Name")
            if not point_name:
                _log.warning(f"Registry configuration contained entry without a point name: {regDef}")
                continue
            read_only = regDef.get('Writable', "").lower() != 'true'
            readable = regDef.get('Readable', "").lower() == 'true'
            volttron_point_name = regDef.get('Volttron Point Name')
            if not volttron_point_name:
                volttron_point_name = point_name
            description = regDef.get('Notes', '')
            units = regDef.get('Units', None)
            default_value = regDef.get("Default Value", "").strip()
            # Truncate empty string or 0 values to None
            if not default_value:
                default_value = None
            type_name = regDef.get("Type", 'string')
            # Create an instance of the register class based on the register type
            if type_name.lower().startswith("setting"):
                register = Setting(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units,
                                   description=description)
            elif type_name.lower() == "hold":
                if first_hold:
                    _log.warning("Hold registers' set_point requires dictionary value, for best practices, visit "
                                 "https://www.ecobee.com/home/developer/api/documentation/v1/functions/SetHold.shtml")
                    first_hold = False
                register = Hold(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units,
                                description=description)
            else:
                _log.warning(f"Unsupported register type {type_name} in Ecobee registry configuration")
                continue
            if default_value is not None:
                self.set_default(point_name, default_value)
            # Add the register instance to our list of registers
            self.insert_register(register)

        # Each Ecobee thermostat has one Status reporting "register", one programs register and one vacation "register

        # Status is a static point which reports a list of running HVAC systems reporting to the thermostat
        status_register = Status(self.ecobee_id)
        self.insert_register(status_register)

        # Vacation can be used to manage all Vacation programs for the thermostat
        vacation_register = Vacation(self.ecobee_id)
        self.insert_register(vacation_register)

        # Add a register for listing events and resuming programs
        program_register = Program(self.ecobee_id)
        self.insert_register(program_register)

    def update_authorization(self):
        if self.authorization_stage == "UNAUTHORIZED":
            self.authorize_application()
        if self.authorization_stage == "REQUEST_TOKENS":
            self.request_tokens()
        if self.authorization_stage == "REFRESH_TOKENS":
            self.refresh_tokens()
        self.update_auth_config()

    def authorize_application(self):
        auth_url = "https://api.ecobee.com/authorize"
        params = {
            "response_type": "ecobeePin",
            "client_id": self.api_key,
            "scope": "smartWrite"
        }
        try:
            response = make_ecobee_request("GET", auth_url, params=params)
        except (ConnectionError, NewConnectionError) as re:
            _log.error(re)
            _log.warning("Error connecting to Ecobee, Could not request pin.")
            return
        for auth_item in ['code', 'ecobeePin']:
            if auth_item not in response:
                raise RuntimeError(f"Ecobee authorization response was missing required item: {auth_item}, response "
                                   "contained {response}")
        self.authorization_code = response.get('code')
        pin = response.get('ecobeePin')
        _log.warning("***********************************************************")
        _log.warning(
            f'Please authorize your Ecobee developer app with PIN code {pin}.\nGo to '
            'https://www.ecobee.com/consumerportal /index.html, click My Apps, Add application, Enter Pin and click '
            'Authorize.')
        _log.warning("***********************************************************")
        self.authorization_stage = "REQUEST_TOKENS"
        gevent.sleep(60)

    def request_tokens(self):
        """
        Request up to date Auth tokens from Ecobee using API key and authorization code
        """
        # Generate auth request and extract returned value
        _log.debug("Requesting new auth tokens from Ecobee.")
        url = 'https://api.ecobee.com/token'
        params = {
            'grant_type': 'ecobeePin',
            'code': self.authorization_code,
            'client_id': self.api_key
        }
        response = make_ecobee_request("POST", url, data=params)
        for token in ["access_token", "refresh_token"]:
            if token not in response:
                raise RuntimeError(f"Request tokens response did  not contain {token}: {response}")
        self.access_token = response.get('access_token')
        self.refresh_token = response.get('refresh_token')
        self.authorization_stage = "AUTHORIZED"

    def refresh_tokens(self):
        """
        Refresh Ecobee API authentication tokens via API endpoint - asks Ecobee to reset tokens then updates config with
        new tokens from Ecobee
        """
        _log.info('Refreshing Ecobee auth tokens.')
        url = 'https://api.ecobee.com/token'
        params = {
            'grant_type': 'refresh_token',
            'refresh_token': self.refresh_token,
            'client_id': self.api_key
        }
        # Generate auth request and extract returned value
        response = make_ecobee_request("POST", url, data=params)
        for token in 'access_token', 'refresh_token':
            if token not in response:
                raise RuntimeError(f"Ecobee response did not contain token {token}:, response was {response}")
        self.access_token = response['access_token']
        self.refresh_token = response['refresh_token']
        self.authorization_stage = "AUTHORIZED"

    def update_auth_config(self):
        """
        Update the platform driver configuration for this device with new values from auth functions
        """
        auth_config = {"AUTH_CODE": self.authorization_code,
                       "ACCESS_TOKEN": self.access_token,
                       "REFRESH_TOKEN": self.refresh_token}
        _log.debug("Updating Ecobee auth configuration with new tokens.")
        self.vip.rpc.call(CONFIGURATION_STORE, "set_config", self.auth_config_path, auth_config, trigger_callback=False,
                          send_update=False).get(timeout=3)

    def get_auth_config_from_store(self):
        """
        :return: Fetch currently stored auth configuration info from config store, returns empty dict if none is
        present
        """
        configs = self.vip.rpc.call(CONFIGURATION_STORE, "manage_list_configs", PLATFORM_DRIVER).get(timeout=3)
        if self.auth_config_path in configs:
            return jsonapi.loads(self.vip.rpc.call(
                CONFIGURATION_STORE, "manage_get", PLATFORM_DRIVER, self.auth_config_path).get(timeout=3))
        else:
            _log.warning("No Ecobee auth file found in config store")
            return {}

    def get_thermostat_data(self, refresh=False):
        """
        Collects most up to date thermostat object data for the configured Ecobee thermostat ID
        :param refresh: whether or not to force obtaining new data from the remote Ecobee API
        """
        params = {
            "json": jsonapi.dumps({
                "selection": {
                    "selectionType": "thermostats",
                    "selectionMatch": self.ecobee_id,
                    "includeSensors": True,
                    "includeRuntime": True,
                    "includeEvents": True,
                    "includeEquipmentStatus": True,
                    "includeSettings": True
                }
            })
        }
        headers = populate_thermostat_headers(self.access_token)
        self.thermostat_data = self.get_ecobee_data("GET", THERMOSTAT_URL, 180, refresh=refresh, headers=headers,
                                                    params=params)

    def get_ecobee_data(self, request_type, url, update_frequency, refresh=False, **kwargs):
        """
        Checks cache for up to date Ecobee data. If none is available for the URL, makes a request to remote Ecobee API.
        :param refresh: force Ecobee data to be obtained from the remote API rather than cache
        :param request_type: HTTP request type for request sent to remote
        :param url: URL of remote Ecobee API endpoint
        :param update_frequency: period for which cached data is considered up to date
        :param kwargs: HTTP request arguments
        :return: Up to date Ecobee data for URL
        """
        cache_data = self.get_data_cache(url, update_frequency)
        if refresh or not (isinstance(cache_data, dict) and len(cache_data)):
            try:
                response = self.get_data_remote(request_type, url, **kwargs)
            except HTTPError as he:
                self.store_remote_data(url, None)
                raise he
            self.store_remote_data(url, response)
            return response
        else:
            return cache_data

    def get_data_remote(self, request_type, url, **kwargs):
        """
        Make request to Ecobee remote API for "register" data, updating authorization tokens as necessary
        :param request_type: HTTP request type for making request
        :param url: URL corresponding to "register" data
        :param kwargs: HTTP request arguments
        :return: remote API response body
        """
        try:
            response = make_ecobee_request(request_type, url, **kwargs)
            self.authorization_stage = "AUTHORIZED"
            return response
        except HTTPError:
            _log.warning(f"HTTPError occurred while fetching data from Ecobee API url: {url}")
            # The request to the remote failed, try refreshing the tokens and trying again using the refresh token
            self.authorization_stage = "REFRESH_TOKENS"
            try:
                self.update_authorization()
            except HTTPError:
                _log.warning("HTTPError occurred while refreshing Ecobee API tokens")
                # if tokens could not be refreshed, try obtaining new tokens using the existing authorization key
                self.authorization_stage = "REQUEST_TOKENS"
                # if we fail to request new tokens, the authorization key is no longer valid, the driver will need
                # to be restarted
                self.update_authorization()
            response = make_ecobee_request(request_type, url, **kwargs)
            self.authorization_stage = "AUTHORIZED"
            return response

    def get_data_cache(self, url, update_frequency):
        """
        Fetches data from cache dict if it is up to date
        :param url: URL to use to use as lookup value in cache dict
        :param update_frequency: duration in seconds for which data in cache is considered up to date
        :return: Data stored in cache if up to date, otherwise None
        """
        url_data = self.cache.get(url)
        if url_data:
            timestamp = utils.parse_timestamp_string(url_data.get("request_timestamp"))
            if (datetime.datetime.now() - timestamp).total_seconds() < update_frequency:
                return url_data.get("request_response")
            else:
                _log.info("Cached Ecobee data out of date.")
        return None

    def store_remote_data(self, url, response):
        """
        Store response body with a timestamp for a given URL
        :param url: url to use to use as lookup value in cache dict
        :param response: request response body to store in cache
        """
        timestamp = utils.format_timestamp(datetime.datetime.now())
        self.cache.update({
            url: {
                "request_timestamp": timestamp,
                "request_response": response
            }
        })
        _log.info(f"Last Ecobee update occurred at {timestamp}")
        self.cache.sync()

    def get_point(self, point_name, **kwargs):
        """
        Return a point's most recent stored value from remote API
        :param point_name: The name of the point corresponding to a register to get the state of
        :return: register's most recent state from remote API response
        """
        # Find the named register and get its current state from the periodic Ecobee API data
        register = self.get_register_by_name(point_name)
        try:
            return register.get_state(self.thermostat_data)
        except (ValueError, KeyError, TypeError):
            self.get_thermostat_data(refresh=True)
            return register.get_state(self.thermostat_data)

    def _set_point(self, point_name, value, **kwargs):
        """
        Send request to remote API to update a point based on provided parameters
        :param point_name: Name of the point to update
        :param value: Intended update value
        :return: Updated state from remote API
        """
        # Find the correct register by name, set its state, then fetch the new state based on the register's type
        register = self.get_register_by_name(point_name)
        if register.read_only:
            raise IOError(f"Trying to write to a point configured read only: {point_name}")
        try:
            if isinstance(register, Setting) or isinstance(register, Hold):
                register.set_state(value, self.access_token)
            elif isinstance(register, Vacation) or isinstance(register, Program):
                register.set_state(value, self.access_token, **kwargs)
        except HTTPError:
            self.refresh_tokens()
            if isinstance(register, Setting) or isinstance(register, Hold):
                register.set_state(value, self.access_token)
            elif isinstance(register, Vacation) or isinstance(register, Program):
                register.set_state(value, self.access_token, **kwargs)
        self.get_thermostat_data(refresh=True)
        if register.readable:
            return register.get_state(self.thermostat_data)

    def _scrape_all(self):
        """
        Fetch point data for all configured points
        :return: dictionary of most recent data for all points configured for the driver
        """
        result = {}
        byte_registers = self.get_registers_by_type("byte", True) + self.get_registers_by_type("byte", False)
        registers = [register for register in byte_registers if register.readable]
        refresh = True
        # Add data for all holds and settings to our results
        for register in registers:
            try:
                register_data = register.get_state(self.thermostat_data)
                if isinstance(register_data, dict):
                    result.update(register_data)
                else:
                    result[register.point_name] = register_data
            except ValueError:
                if refresh is True:
                    # refresh data, but don't create a non-deterministic loop of refreshes
                    self.get_thermostat_data(refresh=refresh)
                    refresh = False
                    register_data = register.get_state(self.thermostat_data)
                    if isinstance(register_data, dict):
                        result.update(register_data)
                    else:
                        result[register.point_name] = register_data
        return result