Skip to content

Commit

Permalink
(experimentally) support multiple api users
Browse files Browse the repository at this point in the history
and add 'permissions' support to switch/commandline component (only)

see
* https://www.pivotaltracker.com/n/projects/1250084/stories/116678871 'User Management',
* https://community.home-assistant.io/t/multiple-users-accounts/396/14 'Multiple Users/Acccounts' Feature Request
* https://community.home-assistant.io/t/multiple-users-account/328/10 'Multiple Users/Accounts' Configuration

Current status:
* support multiple api users now, defined by http.api_users configuration dict, see example below
* add api_users attribute to http component, set to username of the api_user that is logged in
* auth backend uses pbkdf2.hmac now for storing only password hashes of api_users, to avoid that plain passwords of all api users can be found in the configuration.
* components/switch/command_line component (and only this one!) supports 'permission' configuration which specifies the allowed permissions per api user as dictionary (api username => 'rwx'    # 'r'ead 'w'rite e'x'ecute, '*' as api username means 'all other api users'); see example below

Restrictions (in this experimental commit):
* Implementation is only done in the python backend as of now, this means that still all items
are visible in th web view, and all items can be triggered - but for those where user does not
have 'w'rite access the stored state is not overwritten, i.e. the switch in the gui toggles back
to the original value on GUI refresh, as the state could not be changed in the backend.
* pbkdf2_hmac salt currently is fixed - this should be changed to a per-installation generated random

Example configuration snippet:

http:
  # Uncomment this to add a password (recommended!)
  api_password: MyPASSWORD
  api_users:	# uses password hashes, as created by script/pwd2hash.py
	admin:
	  # passphrase 'admin1234':
	  password_hash: "22c377f92775d7145752ecafd182458bdb04bbaa3e3ac0d58832c782f5a57c2b"
	user1:
	  # passphrase 'user1234':
	  password_hash: "ab881c7fe60ae3aa12613aa44bc6199118475c52c6790f9aaf7aa9f383c70d1c"
	user2:
	  # passphrase 'user4321':
	  password_hash: "9ad239323284c47e975d85cb16c39f88eb34fe154de26baa589c79163ccea8c1"

switch:
  - platform: command_line
	switches:
	  one:
		command_on: logger switch.one says command_on
		command_off: logger switch.one says command_off
	# no permissions specified - all have access
	  admin_only:
		command_on: logger switch.admin_only says command_on
		command_off: logger switch.admin_only says command_off
		permissions:
		  'admin': 'rwx'	# all others do not have access
	  admin_or_user1:
		command_on: logger switch.one says command_on
		command_off: logger switch.one says command_off
		permissions:
		  admin: rwx
		  user1: rw
		  '*': r			# all others have read only access
  • Loading branch information
k-laus committed Jan 2, 2017
1 parent cf714f4 commit fc18173
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 33 deletions.
9 changes: 7 additions & 2 deletions homeassistant/components/http/__init__.py
Expand Up @@ -39,6 +39,7 @@
REQUIREMENTS = ('aiohttp_cors==0.5.0',)

CONF_API_PASSWORD = 'api_password'
CONF_API_USERS = 'api_users'
CONF_SERVER_HOST = 'server_host'
CONF_SERVER_PORT = 'server_port'
CONF_BASE_URL = 'base_url'
Expand Down Expand Up @@ -82,6 +83,7 @@

HTTP_SCHEMA = vol.Schema({
vol.Optional(CONF_API_PASSWORD, default=None): cv.string,
vol.Optional(CONF_API_USERS, default=None): dict,
vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string,
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT):
vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
Expand Down Expand Up @@ -113,6 +115,7 @@ def async_setup(hass, config):
conf = HTTP_SCHEMA({})

api_password = conf[CONF_API_PASSWORD]
api_users = conf[CONF_API_USERS]
server_host = conf[CONF_SERVER_HOST]
server_port = conf[CONF_SERVER_PORT]
development = conf[CONF_DEVELOPMENT] == '1'
Expand Down Expand Up @@ -140,7 +143,8 @@ def async_setup(hass, config):
use_x_forwarded_for=use_x_forwarded_for,
trusted_networks=trusted_networks,
login_threshold=login_threshold,
is_ban_enabled=is_ban_enabled
is_ban_enabled=is_ban_enabled,
api_users=api_users
)

@asyncio.coroutine
Expand Down Expand Up @@ -181,7 +185,7 @@ class HomeAssistantWSGI(object):
def __init__(self, hass, development, api_password, ssl_certificate,
ssl_key, server_host, server_port, cors_origins,
use_x_forwarded_for, trusted_networks,
login_threshold, is_ban_enabled):
login_threshold, is_ban_enabled, api_users):
"""Initialize the WSGI Home Assistant server."""
import aiohttp_cors

Expand All @@ -201,6 +205,7 @@ def __init__(self, hass, development, api_password, ssl_certificate,
self.hass = hass
self.development = development
self.api_password = api_password
self.api_users = api_users
self.ssl_certificate = ssl_certificate
self.ssl_key = ssl_key
self.server_host = server_host
Expand Down
37 changes: 34 additions & 3 deletions homeassistant/components/http/auth.py
@@ -1,7 +1,10 @@
"""Authentication for HTTP component."""
import asyncio
import binascii
import hashlib
import hmac
import logging
import os

from homeassistant.const import HTTP_HEADER_HA_AUTH
from .util import get_real_ip
Expand Down Expand Up @@ -59,8 +62,36 @@ def is_trusted_ip(request):
ip_addr in trusted_network for trusted_network
in request.app[KEY_TRUSTED_NETWORKS])

def hash_password(password, salt=None):
"""Create hash from password"""
# TODO: randomize salt per installation and store it in configuration
salt = b'\x02O\xc0P?\x16\xc4\xdb\xbe\x96\xba\xb4\xa9r\x87\xe0'
iterations = 100000
dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt,
iterations)
hashed_passwd = binascii.hexlify(dk)
return hashed_passwd

def validate_password(request, api_password):
"""Test if password is valid."""
return hmac.compare_digest(api_password,
request.app['hass'].http.api_password)
"""
Test if one of the passwords is valid: first try the http.api_password, then
all the http.api_users' api_passwords.
"""
# first, try old-style, only one api password
validated = hmac.compare_digest(api_password,
request.app['hass'].http.api_password)
if validated:
_LOGGER.debug("validation with old-style api_password was successful.")
return validated
if request.app['hass'].http.api_users is not None:
hashed_passwd = hash_password(api_password)
for api_user in request.app['hass'].http.api_users:
pw_hash = request.app['hass'].http.api_users[api_user]\
['password_hash'].encode('utf-8')
validated = hmac.compare_digest(hashed_passwd, pw_hash)
if validated:
_LOGGER.debug("api password_hash matched for user '%s'" % api_user)
# remember current api username
request.app['hass'].http.api_user = api_user
break
return validated
76 changes: 66 additions & 10 deletions homeassistant/components/switch/command_line.py
Expand Up @@ -12,8 +12,9 @@
from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA,
ENTITY_ID_FORMAT)
from homeassistant.const import (
CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, CONF_COMMAND_OFF,
CONF_COMMAND_ON, CONF_COMMAND_STATE)
CONF_ANY_USER, CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE,
CONF_FRIENDLY_NAME, CONF_PERMISSIONS, CONF_SWITCHES, CONF_VALUE_TEMPLATE )
from homeassistant.exceptions import PermissionDenied
import homeassistant.helpers.config_validation as cv

_LOGGER = logging.getLogger(__name__)
Expand All @@ -24,6 +25,7 @@
vol.Optional(CONF_COMMAND_STATE): cv.string,
vol.Optional(CONF_FRIENDLY_NAME): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_PERMISSIONS, default=None): dict,
})

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
Expand Down Expand Up @@ -51,7 +53,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
device_config.get(CONF_COMMAND_ON),
device_config.get(CONF_COMMAND_OFF),
device_config.get(CONF_COMMAND_STATE),
value_template
value_template,
device_config.get(CONF_PERMISSIONS)
)
)

Expand All @@ -63,10 +66,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):


class CommandSwitch(SwitchDevice):
"""Representation a switch that can be toggled using shell commands."""
"""Representation of a switch that can be toggled using shell commands."""

def __init__(self, hass, object_id, friendly_name, command_on,
command_off, command_state, value_template):
command_off, command_state, value_template, permissions):
"""Initialize the switch."""
self._hass = hass
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
Expand All @@ -76,6 +79,7 @@ def __init__(self, hass, object_id, friendly_name, command_on,
self._command_off = command_off
self._command_state = command_state
self._value_template = value_template
self._permissions = permissions

@staticmethod
def _switch(command):
Expand Down Expand Up @@ -121,6 +125,14 @@ def is_on(self):
"""Return true if device is on."""
return self._state

@property
def permissions(self):
"""
Return the permission dictionary of the current entity,
is None if no special permissions are set for current entity.
"""
return self._permissions

@property
def assumed_state(self):
"""Return true if we do optimistic updates."""
Expand All @@ -131,29 +143,73 @@ def _query_state(self):
if not self._command_state:
_LOGGER.error('No state command specified')
return
if self.has_perm(perm='r') is False:
_LOGGER.error("current user does not have permission to query "
"state of %s" % self._name)
return
if self._value_template:
return CommandSwitch._query_state_value(self._command_state)
return CommandSwitch._query_state_code(self._command_state)

def update(self):
"""Update device state."""
if self._command_state:
_LOGGER.debug("update() of %s: command_state %s" % (self._name, self._command_state))
if self.has_perm(perm='w') and self._command_state:
payload = str(self._query_state())
if self._value_template:
payload = self._value_template.render_with_possible_json_value(
payload)
self._state = (payload.lower() == "true")

def turn_on(self, **kwargs):
"""Turn the device on."""
if (CommandSwitch._switch(self._command_on) and
"""Turn the device on, if user is permitted to."""
if (self.has_perm(perm='w') and
CommandSwitch._switch(self._command_on) and
not self._command_state):
self._state = True
self.schedule_update_ha_state()

def turn_off(self, **kwargs):
"""Turn the device off."""
if (CommandSwitch._switch(self._command_off) and
"""Turn the device off, if user is permitted to."""
if (self.has_perm(perm='w') and
CommandSwitch._switch(self._command_off) and
not self._command_state):
self._state = False
self.schedule_update_ha_state()

def has_perm(self, perm='r'):
"""Return if currently logged in api_user has permission 'perm'."""
user = None
try:
user = self._hass.http.api_user
except AttributeError: # no http object exists or no api_user set
pass
if user is None:
user = CONF_ANY_USER
if self._permissions is None:
user_perm = 'rwx' # no restrictions set for current entity
else:
user_perm = self.permissions.get(user, '')
if not contains_perm(user_perm, perm):
msg = "User '%s' does not have '%s' permission for '%s', only " \
"has '%s'." % (user, perm, self._name, user_perm)
_LOGGER.error(msg)
raise PermissionDenied(msg)
return True


def contains_perm(perm, requested_perm='r'):
"""
Return true if user has permission to access the component, else false.
perm 'r' means user has read access, i.e. sees the component,
perm 'w' means that user can write to the component (change its state)
If multiple permissions are mentioned then only return true if user has all.
"""
if perm is None:
return False
if perm.find(requested_perm) >= 0:
# TODO: maybe using find() is too easy, if permissions are written
# without using same order (rxw instead of rwx) - that won't match
return True
else:
return False
2 changes: 2 additions & 0 deletions homeassistant/const.py
Expand Up @@ -58,6 +58,7 @@
CONF_AFTER = 'after'
CONF_ALIAS = 'alias'
CONF_API_KEY = 'api_key'
CONF_ANY_USER = '*'
CONF_AUTHENTICATION = 'authentication'
CONF_BASE = 'base'
CONF_BEFORE = 'before'
Expand Down Expand Up @@ -111,6 +112,7 @@
CONF_PAYLOAD_OFF = 'payload_off'
CONF_PAYLOAD_ON = 'payload_on'
CONF_PENDING_TIME = 'pending_time'
CONF_PERMISSIONS = 'permissions'
CONF_PIN = 'pin'
CONF_PLATFORM = 'platform'
CONF_PORT = 'port'
Expand Down
37 changes: 26 additions & 11 deletions homeassistant/core.py
Expand Up @@ -29,7 +29,7 @@
EVENT_TIME_CHANGED, MATCH_ALL, RESTART_EXIT_CODE,
SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, __version__)
from homeassistant.exceptions import (
HomeAssistantError, InvalidEntityFormatError, ShuttingDown)
HomeAssistantError, InvalidEntityFormatError, PermissionDenied, ShuttingDown)
from homeassistant.util.async import (
run_coroutine_threadsafe, run_callback_threadsafe)
import homeassistant.util as util
Expand Down Expand Up @@ -313,7 +313,10 @@ def _async_exception_handler(self, loop, context):
# Do not report on shutting down exceptions.
if isinstance(exception, ShuttingDown):
return

# Do not report on PermissionDenied exceptions, they just tell
# that current (api) user does not have enough rights
if isinstance(exception, PermissionDenied):
return
kwargs['exc_info'] = (type(exception), exception,
exception.__traceback__)

Expand Down Expand Up @@ -556,25 +559,31 @@ class State(object):
attributes: extra information on entity and state
last_changed: last time the state was changed, not the attributes.
last_updated: last time this object was updated.
permissions: optional, permissions (dict) to map permissions per api user;
if None then allow all, if current api username found as permission
key then use these explicit user-permissions, otherwise fallback to
'*' entry which specifies 'any other' user permissions.
"""

__slots__ = ['entity_id', 'state', 'attributes',
'last_changed', 'last_updated']
'last_changed', 'last_updated', 'permissions']

def __init__(self, entity_id, state, attributes=None, last_changed=None,
last_updated=None):
last_updated=None, permissions=None):
"""Initialize a new state."""
if not valid_entity_id(entity_id):
raise InvalidEntityFormatError((
"Invalid entity id encountered: {}. "
"Format should be <domain>.<object_id>").format(entity_id))

self.entity_id = entity_id.lower()
self.state = str(state)
self.attributes = MappingProxyType(attributes or {})
self.last_updated = last_updated or dt_util.utcnow()

self.last_changed = last_changed or self.last_updated
self.permissions = permissions
_LOGGER.debug("State.__init__ %s" % self)
# import pdb
# pdb.set_trace()

@property
def domain(self):
Expand Down Expand Up @@ -630,22 +639,26 @@ def from_dict(cls, json_dict):
last_updated = dt_util.parse_datetime(last_updated)

return cls(json_dict['entity_id'], json_dict['state'],
json_dict.get('attributes'), last_changed, last_updated)
json_dict.get('attributes'), last_changed, last_updated,
json_dict.get('permissions'))

def __eq__(self, other):
"""Return the comparison of the state."""
return (self.__class__ == other.__class__ and
self.entity_id == other.entity_id and
self.state == other.state and
self.attributes == other.attributes)
self.attributes == other.attributes and
self.permissions == other.permissions)

def __repr__(self):
"""Return the representation of the states."""
attr = "; {}".format(util.repr_helper(self.attributes)) \
if self.attributes else ""
perms = "; {}".format(util.repr_helper(self.permissions)) \
if self.permissions else ""

return "<state {}={}{} @ {}>".format(
self.entity_id, self.state, attr,
return "<state {}={}{}{} @ {}>".format(
self.entity_id, self.state, attr, perms,
dt_util.as_local(self.last_changed).isoformat())


Expand Down Expand Up @@ -777,7 +790,9 @@ def async_set(self, entity_id, new_state, attributes=None,
entity_id = entity_id.lower()
new_state = str(new_state)
attributes = attributes or {}

_LOGGER.debug("core:async_set '%s' to '%s'" % (entity_id, new_state))
_LOGGER.debug("self._states: %s" % self._states)
#import pdb; pdb.set_trace()
old_state = self._states.get(entity_id)

is_existing = old_state is not None
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/exceptions.py
Expand Up @@ -32,3 +32,6 @@ def __init__(self, exception):
"""Initalize the error."""
super().__init__('{}: {}'.format(exception.__class__.__name__,
exception))

class PermissionDenied(HomeAssistantError):
"""not enough permissions, too restrictive for current user."""
14 changes: 14 additions & 0 deletions script/pwd2hash.py
@@ -0,0 +1,14 @@
#!/usr/bin/python
# pwd2pash.py - create password_hash as used in http.api_users.password_hash configs

import binascii
import hashlib
import getpass
import os

plain_passwd = getpass.getpass("Enter plain password: ")
the_salt = b'\x02O\xc0P?\x16\xc4\xdb\xbe\x96\xba\xb4\xa9r\x87\xe0' # os.urandom(16)
iterations = 100000
dk = hashlib.pbkdf2_hmac('sha256', plain_passwd.encode('utf-8'), the_salt, iterations)
print(binascii.hexlify(dk))

0 comments on commit fc18173

Please sign in to comment.