Compare commits

...
Sign in to create a new pull request.

5 commits
dev ... groups

Author SHA1 Message Date
Paulus Schoutsen
a1e4b98fa8 Typing 2018-09-28 15:16:41 +02:00
Paulus Schoutsen
4f26935756 Move backwards compat code to migration 2018-09-28 14:12:20 +02:00
Paulus Schoutsen
b9f004b549 Lint 2018-09-28 13:53:29 +02:00
Paulus Schoutsen
48574cd98b Make group attrs private 2018-09-28 13:03:09 +02:00
Paulus Schoutsen
4f4a14bd62 Add group foundation 2018-09-28 12:53:57 +02:00
14 changed files with 416 additions and 203 deletions

View file

@ -211,7 +211,7 @@ class AuthManager:
async def async_enable_user_mfa(self, user: models.User, async def async_enable_user_mfa(self, user: models.User,
mfa_module_id: str, data: Any) -> None: mfa_module_id: str, data: Any) -> None:
"""Enable a multi-factor auth module for user.""" """Enable a multi-factor auth module for user."""
if user.system_generated: if user.group.system_generated:
raise ValueError('System generated users cannot enable ' raise ValueError('System generated users cannot enable '
'multi-factor auth module.') 'multi-factor auth module.')
@ -225,7 +225,7 @@ class AuthManager:
async def async_disable_user_mfa(self, user: models.User, async def async_disable_user_mfa(self, user: models.User,
mfa_module_id: str) -> None: mfa_module_id: str) -> None:
"""Disable a multi-factor auth module for user.""" """Disable a multi-factor auth module for user."""
if user.system_generated: if user.group.system_generated:
raise ValueError('System generated users cannot disable ' raise ValueError('System generated users cannot disable '
'multi-factor auth module.') 'multi-factor auth module.')
@ -255,18 +255,19 @@ class AuthManager:
if not user.is_active: if not user.is_active:
raise ValueError('User is not active') raise ValueError('User is not active')
if user.system_generated and client_id is not None: if user.group.system_generated and client_id is not None:
raise ValueError( raise ValueError(
'System generated users cannot have refresh tokens connected ' 'System generated users cannot have refresh tokens connected '
'to a client.') 'to a client.')
if token_type is None: if token_type is None:
if user.system_generated: if user.group.system_generated:
token_type = models.TOKEN_TYPE_SYSTEM token_type = models.TOKEN_TYPE_SYSTEM
else: else:
token_type = models.TOKEN_TYPE_NORMAL token_type = models.TOKEN_TYPE_NORMAL
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): if user.group.system_generated != (token_type ==
models.TOKEN_TYPE_SYSTEM):
raise ValueError( raise ValueError(
'System generated users can only have system type ' 'System generated users can only have system type '
'refresh tokens') 'refresh tokens')
@ -414,7 +415,7 @@ class AuthManager:
being created. being created.
""" """
for user in await self._store.async_get_users(): for user in await self._store.async_get_users():
if not user.system_generated: if not user.group.system_generated:
return False return False
return True return True

View file

@ -1,20 +1,85 @@
"""Storage for auth models.""" """Storage for auth models."""
from collections import OrderedDict from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
import hmac
from logging import getLogger from logging import getLogger
from typing import Any, Dict, List, Optional # noqa: F401 from typing import Any, Dict, List, Optional # noqa: F401
import hmac import uuid
from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import storage
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import models from . import models
STORAGE_VERSION = 1 STORAGE_VERSION = 2
STORAGE_KEY = 'auth' STORAGE_KEY = 'auth'
class DataStore(storage.Store):
"""Store the auth data on disk using JSON."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the data store."""
super().__init__(hass, STORAGE_VERSION, STORAGE_KEY, True)
async def _async_migrate_func(self, old_version: int,
old_data: Dict[str, Any]) -> Dict[str, Any]:
"""Migrate to the new version."""
if old_version <= 1:
system_group_id = old_data['system_user_group_id'] = \
uuid.uuid4().hex
family_group_id = old_data['default_new_user_group_id'] = \
uuid.uuid4().hex
old_data['groups'] = [
{
'name': 'System',
'id': system_group_id,
'system_generated': True,
},
{
'name': 'Family',
'id': family_group_id,
'system_generated': False,
},
]
for user_dict in old_data['users']:
if user_dict.pop('system_generated'):
group_id = system_group_id
else:
group_id = family_group_id
user_dict['group_id'] = group_id
refresh_tokens = []
for rt_dict in old_data['refresh_tokens']:
if 'jwt_key' not in rt_dict:
continue
if 'token_type' not in rt_dict:
if rt_dict['client_id'] is None:
token_type = models.TOKEN_TYPE_SYSTEM
else:
token_type = models.TOKEN_TYPE_NORMAL
rt_dict['token_type'] = token_type
rt_dict.setdefault('last_used_at', None)
rt_dict.setdefault('client_name', None)
rt_dict.setdefault('client_icon', None)
rt_dict.setdefault('last_used_ip', None)
refresh_tokens.append(rt_dict)
old_data['refresh_tokens'] = refresh_tokens
return old_data
class AuthStore: class AuthStore:
"""Stores authentication info. """Stores authentication info.
@ -28,8 +93,10 @@ class AuthStore:
"""Initialize the auth store.""" """Initialize the auth store."""
self.hass = hass self.hass = hass
self._users = None # type: Optional[Dict[str, models.User]] self._users = None # type: Optional[Dict[str, models.User]]
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, self._groups = None # type: Optional[Dict[str, models.Group]]
private=True) self._default_new_user_group_id = None # type: Optional[str]
self._system_user_group_id = None # type: Optional[str]
self._store = DataStore(hass)
async def async_get_users(self) -> List[models.User]: async def async_get_users(self) -> List[models.User]:
"""Retrieve all users.""" """Retrieve all users."""
@ -50,15 +117,26 @@ class AuthStore:
async def async_create_user( async def async_create_user(
self, name: Optional[str], is_owner: Optional[bool] = None, self, name: Optional[str], is_owner: Optional[bool] = None,
is_active: Optional[bool] = None, is_active: Optional[bool] = None,
group_id: Optional[str] = None,
system_generated: Optional[bool] = None, system_generated: Optional[bool] = None,
credentials: Optional[models.Credentials] = None) -> models.User: credentials: Optional[models.Credentials] = None) -> models.User:
"""Create a new user.""" """Create a new user."""
if self._users is None: if self._users is None:
await self._async_load() await self._async_load()
assert self._users is not None
assert self._users is not None
assert self._groups is not None
assert self._system_user_group_id is not None
assert self._default_new_user_group_id is not None
if system_generated:
group_id = self._system_user_group_id
elif group_id is None:
group_id = self._default_new_user_group_id
kwargs = { kwargs = {
'name': name 'name': name,
'group': self._groups[group_id],
} # type: Dict[str, Any] } # type: Dict[str, Any]
if is_owner is not None: if is_owner is not None:
@ -67,9 +145,6 @@ class AuthStore:
if is_active is not None: if is_active is not None:
kwargs['is_active'] = is_active kwargs['is_active'] = is_active
if system_generated is not None:
kwargs['system_generated'] = system_generated
new_user = models.User(**kwargs) new_user = models.User(**kwargs)
self._users[new_user.id] = new_user self._users[new_user.id] = new_user
@ -214,14 +289,32 @@ class AuthStore:
if self._users is not None: if self._users is not None:
return return
users = OrderedDict() # type: Dict[str, models.User]
if data is None: if data is None:
self._users = users self._set_defaults()
return return
users = OrderedDict() # type: Dict[str, models.User]
groups = OrderedDict() # type: Dict[str, models.Group]
# When creating objects we mention each attribute explicetely. This
# prevents crashing if user rolls back HA version after a new property
# was added.
for group_dict in data['groups']:
groups[group_dict['id']] = models.Group(
name=group_dict['name'],
id=group_dict['id'],
system_generated=group_dict['system_generated'],
)
for user_dict in data['users']: for user_dict in data['users']:
users[user_dict['id']] = models.User(**user_dict) users[user_dict['id']] = models.User(
name=user_dict['name'],
group=groups[user_dict['group_id']],
id=user_dict['id'],
is_owner=user_dict['is_owner'],
is_active=user_dict['is_active'],
)
for cred_dict in data['credentials']: for cred_dict in data['credentials']:
users[cred_dict['user_id']].credentials.append(models.Credentials( users[cred_dict['user_id']].credentials.append(models.Credentials(
@ -233,10 +326,6 @@ class AuthStore:
)) ))
for rt_dict in data['refresh_tokens']: for rt_dict in data['refresh_tokens']:
# Filter out the old keys that don't have jwt_key (pre-0.76)
if 'jwt_key' not in rt_dict:
continue
created_at = dt_util.parse_datetime(rt_dict['created_at']) created_at = dt_util.parse_datetime(rt_dict['created_at'])
if created_at is None: if created_at is None:
getLogger(__name__).error( getLogger(__name__).error(
@ -244,15 +333,7 @@ class AuthStore:
'%(created_at)s for user_id %(user_id)s', rt_dict) '%(created_at)s for user_id %(user_id)s', rt_dict)
continue continue
token_type = rt_dict.get('token_type') last_used_at_str = rt_dict['last_used_at']
if token_type is None:
if rt_dict['client_id'] is None:
token_type = models.TOKEN_TYPE_SYSTEM
else:
token_type = models.TOKEN_TYPE_NORMAL
# old refresh_token don't have last_used_at (pre-0.78)
last_used_at_str = rt_dict.get('last_used_at')
if last_used_at_str: if last_used_at_str:
last_used_at = dt_util.parse_datetime(last_used_at_str) last_used_at = dt_util.parse_datetime(last_used_at_str)
else: else:
@ -262,21 +343,23 @@ class AuthStore:
id=rt_dict['id'], id=rt_dict['id'],
user=users[rt_dict['user_id']], user=users[rt_dict['user_id']],
client_id=rt_dict['client_id'], client_id=rt_dict['client_id'],
# use dict.get to keep backward compatibility client_name=rt_dict['client_name'],
client_name=rt_dict.get('client_name'), client_icon=rt_dict['client_icon'],
client_icon=rt_dict.get('client_icon'), token_type=rt_dict['token_type'],
token_type=token_type,
created_at=created_at, created_at=created_at,
access_token_expiration=timedelta( access_token_expiration=timedelta(
seconds=rt_dict['access_token_expiration']), seconds=rt_dict['access_token_expiration']),
token=rt_dict['token'], token=rt_dict['token'],
jwt_key=rt_dict['jwt_key'], jwt_key=rt_dict['jwt_key'],
last_used_at=last_used_at, last_used_at=last_used_at,
last_used_ip=rt_dict.get('last_used_ip'), last_used_ip=rt_dict['last_used_ip'],
) )
users[rt_dict['user_id']].refresh_tokens[token.id] = token users[rt_dict['user_id']].refresh_tokens[token.id] = token
self._groups = groups
self._users = users self._users = users
self._default_new_user_group_id = data['default_new_user_group_id']
self._system_user_group_id = data['system_user_group_id']
@callback @callback
def _async_schedule_save(self) -> None: def _async_schedule_save(self) -> None:
@ -290,18 +373,28 @@ class AuthStore:
def _data_to_save(self) -> Dict: def _data_to_save(self) -> Dict:
"""Return the data to store.""" """Return the data to store."""
assert self._users is not None assert self._users is not None
assert self._groups is not None
users = [ users = [
{ {
'id': user.id, 'id': user.id,
'group_id': user.group.id,
'is_owner': user.is_owner, 'is_owner': user.is_owner,
'is_active': user.is_active, 'is_active': user.is_active,
'name': user.name, 'name': user.name,
'system_generated': user.system_generated,
} }
for user in self._users.values() for user in self._users.values()
] ]
groups = [
{
'name': group.name,
'id': group.id,
'system_generated': group.system_generated,
}
for group in self._groups.values()
]
credentials = [ credentials = [
{ {
'id': credential.id, 'id': credential.id,
@ -338,6 +431,26 @@ class AuthStore:
return { return {
'users': users, 'users': users,
'groups': groups,
'credentials': credentials, 'credentials': credentials,
'refresh_tokens': refresh_tokens, 'refresh_tokens': refresh_tokens,
'default_new_user_group_id': self._default_new_user_group_id,
'system_user_group_id': self._system_user_group_id,
} }
def _set_defaults(self) -> None:
"""Set default values for auth store."""
self._users = OrderedDict() # type: Dict[str, models.User]
# Add default groups
system_group = models.Group(name='System', system_generated=True)
family_group = models.Group(name='Family')
self._default_new_user_group_id = family_group.id
self._system_user_group_id = system_group.id
groups = OrderedDict() # type: Dict[str, models.Group]
groups[system_group.id] = system_group
groups[family_group.id] = family_group
self._groups = groups

View file

@ -14,15 +14,25 @@ TOKEN_TYPE_SYSTEM = 'system'
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token'
@attr.s(slots=True)
class Group:
"""A group."""
name = attr.ib(type=str) # type: Optional[str]
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
# System generated groups cannot be changed
system_generated = attr.ib(type=bool, default=False)
@attr.s(slots=True) @attr.s(slots=True)
class User: class User:
"""A user.""" """A user."""
name = attr.ib(type=str) # type: Optional[str] name = attr.ib(type=str) # type: Optional[str]
group = attr.ib(type=Group)
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
is_owner = attr.ib(type=bool, default=False) is_owner = attr.ib(type=bool, default=False)
is_active = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False)
system_generated = attr.ib(type=bool, default=False)
# List of credentials of a user. # List of credentials of a user.
credentials = attr.ib( credentials = attr.ib(

View file

@ -44,9 +44,9 @@ class TrustedNetworksAuthProvider(AuthProvider):
"""Return a flow to login.""" """Return a flow to login."""
assert context is not None assert context is not None
users = await self.store.async_get_users() users = await self.store.async_get_users()
available_users = {user.id: user.name available_users = {
for user in users user.id: user.name for user in users
if not user.system_generated and user.is_active} if not user.group.system_generated and user.is_active}
return TrustedNetworksLoginFlow( return TrustedNetworksLoginFlow(
self, cast(str, context.get('ip_address')), available_users) self, cast(str, context.get('ip_address')), available_users)
@ -58,7 +58,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
users = await self.store.async_get_users() users = await self.store.async_get_users()
for user in users: for user in users:
if (not user.system_generated and if (not user.group.system_generated and
user.is_active and user.is_active and
user.id == user_id): user.id == user_id):
for credential in await self.async_credentials(): for credential in await self.async_credentials():

View file

@ -1,7 +1,6 @@
"""Offer API to configure Home Assistant auth.""" """Offer API to configure Home Assistant auth."""
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
@ -40,61 +39,49 @@ async def async_setup(hass):
return True return True
@callback
@websocket_api.require_owner @websocket_api.require_owner
def websocket_list(hass, connection, msg): @websocket_api.async_response
async def websocket_list(hass, connection, msg):
"""Return a list of users.""" """Return a list of users."""
async def send_users(): result = [_user_info(u) for u in await hass.auth.async_get_users()]
"""Send users."""
result = [_user_info(u) for u in await hass.auth.async_get_users()]
connection.send_message_outside( connection.send_message_outside(
websocket_api.result_message(msg['id'], result)) websocket_api.result_message(msg['id'], result))
hass.async_add_job(send_users())
@callback
@websocket_api.require_owner @websocket_api.require_owner
def websocket_delete(hass, connection, msg): @websocket_api.async_response
async def websocket_delete(hass, connection, msg):
"""Delete a user.""" """Delete a user."""
async def delete_user(): if msg['user_id'] == connection.request.get('hass_user').id:
"""Delete user.""" connection.send_message_outside(websocket_api.error_message(
if msg['user_id'] == connection.request.get('hass_user').id: msg['id'], 'no_delete_self',
connection.send_message_outside(websocket_api.error_message( 'Unable to delete your own account'))
msg['id'], 'no_delete_self', return
'Unable to delete your own account'))
return
user = await hass.auth.async_get_user(msg['user_id']) user = await hass.auth.async_get_user(msg['user_id'])
if not user: if not user:
connection.send_message_outside(websocket_api.error_message( connection.send_message_outside(websocket_api.error_message(
msg['id'], 'not_found', 'User not found')) msg['id'], 'not_found', 'User not found'))
return return
await hass.auth.async_remove_user(user) await hass.auth.async_remove_user(user)
connection.send_message_outside( connection.send_message_outside(
websocket_api.result_message(msg['id'])) websocket_api.result_message(msg['id']))
hass.async_add_job(delete_user())
@callback
@websocket_api.require_owner @websocket_api.require_owner
def websocket_create(hass, connection, msg): @websocket_api.async_response
async def websocket_create(hass, connection, msg):
"""Create a user.""" """Create a user."""
async def create_user(): user = await hass.auth.async_create_user(msg['name'])
"""Create a user."""
user = await hass.auth.async_create_user(msg['name'])
connection.send_message_outside( connection.send_message_outside(
websocket_api.result_message(msg['id'], { websocket_api.result_message(msg['id'], {
'user': _user_info(user) 'user': _user_info(user)
})) }))
hass.async_add_job(create_user())
def _user_info(user): def _user_info(user):
@ -104,7 +91,9 @@ def _user_info(user):
'name': user.name, 'name': user.name,
'is_owner': user.is_owner, 'is_owner': user.is_owner,
'is_active': user.is_active, 'is_active': user.is_active,
'system_generated': user.system_generated, # Temp, backwards compat since 0.80, remove in 85
'system_generated': user.group.system_generated,
'group_id': user.group.id,
'credentials': [ 'credentials': [
{ {
'type': c.auth_provider_type, 'type': c.auth_provider_type,

View file

@ -2,7 +2,6 @@
import voluptuous as vol import voluptuous as vol
from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.core import callback
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
@ -54,121 +53,109 @@ def _get_provider(hass):
raise RuntimeError('Provider not found') raise RuntimeError('Provider not found')
@callback
@websocket_api.require_owner @websocket_api.require_owner
def websocket_create(hass, connection, msg): @websocket_api.async_response
async def websocket_create(hass, connection, msg):
"""Create credentials and attach to a user.""" """Create credentials and attach to a user."""
async def create_creds(): provider = _get_provider(hass)
"""Create credentials.""" await provider.async_initialize()
provider = _get_provider(hass)
await provider.async_initialize()
user = await hass.auth.async_get_user(msg['user_id']) user = await hass.auth.async_get_user(msg['user_id'])
if user is None: if user is None:
connection.send_message_outside(websocket_api.error_message( connection.send_message_outside(websocket_api.error_message(
msg['id'], 'not_found', 'User not found')) msg['id'], 'not_found', 'User not found'))
return return
if user.system_generated: if user.group.system_generated:
connection.send_message_outside(websocket_api.error_message( connection.send_message_outside(websocket_api.error_message(
msg['id'], 'system_generated', msg['id'], 'system_generated',
'Cannot add credentials to a system generated user.')) 'Cannot add credentials to a system generated user.'))
return return
try: try:
await hass.async_add_executor_job( await hass.async_add_executor_job(
provider.data.add_auth, msg['username'], msg['password']) provider.data.add_auth, msg['username'], msg['password'])
except auth_ha.InvalidUser: except auth_ha.InvalidUser:
connection.send_message_outside(websocket_api.error_message( connection.send_message_outside(websocket_api.error_message(
msg['id'], 'username_exists', 'Username already exists')) msg['id'], 'username_exists', 'Username already exists'))
return return
credentials = await provider.async_get_or_create_credentials({ credentials = await provider.async_get_or_create_credentials({
'username': msg['username'] 'username': msg['username']
}) })
await hass.auth.async_link_user(user, credentials) await hass.auth.async_link_user(user, credentials)
await provider.data.async_save() await provider.data.async_save()
connection.to_write.put_nowait(websocket_api.result_message(msg['id'])) connection.to_write.put_nowait(websocket_api.result_message(msg['id']))
hass.async_add_job(create_creds())
@callback
@websocket_api.require_owner @websocket_api.require_owner
def websocket_delete(hass, connection, msg): @websocket_api.async_response
async def websocket_delete(hass, connection, msg):
"""Delete username and related credential.""" """Delete username and related credential."""
async def delete_creds(): provider = _get_provider(hass)
"""Delete user credentials.""" await provider.async_initialize()
provider = _get_provider(hass)
await provider.async_initialize()
credentials = await provider.async_get_or_create_credentials({ credentials = await provider.async_get_or_create_credentials({
'username': msg['username'] 'username': msg['username']
}) })
# if not new, an existing credential exists. # if not new, an existing credential exists.
# Removing the credential will also remove the auth. # Removing the credential will also remove the auth.
if not credentials.is_new: if not credentials.is_new:
await hass.auth.async_remove_credentials(credentials) await hass.auth.async_remove_credentials(credentials)
connection.to_write.put_nowait(
websocket_api.result_message(msg['id']))
return
try:
provider.data.async_remove_auth(msg['username'])
await provider.data.async_save()
except auth_ha.InvalidUser:
connection.to_write.put_nowait(websocket_api.error_message(
msg['id'], 'auth_not_found', 'Given username was not found.'))
return
connection.to_write.put_nowait( connection.to_write.put_nowait(
websocket_api.result_message(msg['id'])) websocket_api.result_message(msg['id']))
return
hass.async_add_job(delete_creds()) try:
provider.data.async_remove_auth(msg['username'])
@callback
def websocket_change_password(hass, connection, msg):
"""Change user password."""
async def change_password():
"""Change user password."""
user = connection.request.get('hass_user')
if user is None:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'user_not_found', 'User not found'))
return
provider = _get_provider(hass)
await provider.async_initialize()
username = None
for credential in user.credentials:
if credential.auth_provider_type == provider.type:
username = credential.data['username']
break
if username is None:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'credentials_not_found', 'Credentials not found'))
return
try:
await provider.async_validate_login(
username, msg['current_password'])
except auth_ha.InvalidAuth:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'invalid_password', 'Invalid password'))
return
await hass.async_add_executor_job(
provider.data.change_password, username, msg['new_password'])
await provider.data.async_save() await provider.data.async_save()
except auth_ha.InvalidUser:
connection.to_write.put_nowait(websocket_api.error_message(
msg['id'], 'auth_not_found', 'Given username was not found.'))
return
connection.send_message_outside( connection.to_write.put_nowait(
websocket_api.result_message(msg['id'])) websocket_api.result_message(msg['id']))
hass.async_add_job(change_password())
@websocket_api.async_response
async def websocket_change_password(hass, connection, msg):
"""Change user password."""
user = connection.request.get('hass_user')
if user is None:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'user_not_found', 'User not found'))
return
provider = _get_provider(hass)
await provider.async_initialize()
username = None
for credential in user.credentials:
if credential.auth_provider_type == provider.type:
username = credential.data['username']
break
if username is None:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'credentials_not_found', 'Credentials not found'))
return
try:
await provider.async_validate_login(
username, msg['current_password'])
except auth_ha.InvalidAuth:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'invalid_password', 'Invalid password'))
return
await hass.async_add_executor_job(
provider.data.change_password, username, msg['new_password'])
await provider.data.async_save()
connection.send_message_outside(
websocket_api.result_message(msg['id']))

View file

@ -480,22 +480,24 @@ class ActiveConnection:
return wsock return wsock
async def _handle_async_response(func, hass, connection, msg):
"""Create a response and handle exception."""
try:
await func(hass, connection, msg)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
connection.send_message_outside(error_message(
msg['id'], 'unknown', 'Unexpected error occurred'))
def async_response(func): def async_response(func):
"""Decorate an async function to handle WebSocket API messages.""" """Decorate an async function to handle WebSocket API messages."""
async def handle_msg_response(hass, connection, msg):
"""Create a response and handle exception."""
try:
await func(hass, connection, msg)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
connection.send_message_outside(error_message(
msg['id'], 'unknown', 'Unexpected error occurred'))
@callback @callback
@wraps(func) @wraps(func)
def schedule_handler(hass, connection, msg): def schedule_handler(hass, connection, msg):
"""Schedule the handler.""" """Schedule the handler."""
hass.async_create_task(handle_msg_response(hass, connection, msg)) hass.async_create_task(
_handle_async_response(func, hass, connection, msg))
return schedule_handler return schedule_handler
@ -619,13 +621,13 @@ def ws_require_user(
return return
if (only_system_user and if (only_system_user and
not connection.user.system_generated): not connection.user.group.system_generated):
output_error('only_system_user', output_error('only_system_user',
'Only allowed as system user') 'Only allowed as system user')
return return
if (not allow_system_user if (not allow_system_user
and connection.user.system_generated): and connection.user.group.system_generated):
output_error('not_system_user', 'Not allowed as system user') output_error('not_system_user', 'Not allowed as system user')
return return

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
import logging import logging
import os import os
from typing import Dict, Optional, Callable from typing import Dict, Optional, Callable, Any
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback from homeassistant.core import callback
@ -63,7 +63,7 @@ class Store:
"""Return the config path.""" """Return the config path."""
return self.hass.config.path(STORAGE_DIR, self.key) return self.hass.config.path(STORAGE_DIR, self.key)
async def async_load(self): async def async_load(self) -> Optional[Dict[str, Any]]:
"""Load data. """Load data.
If the expected version does not match the given version, the migrate If the expected version does not match the given version, the migrate

View file

@ -0,0 +1,47 @@
"""Test the auth store."""
import json
from homeassistant.auth import auth_store, models
from tests.common import load_fixture
async def test_migration_v1_v2(hass, hass_storage):
"""Test migrating auth store to add groups."""
hass_storage[auth_store.STORAGE_KEY] = json.loads(
load_fixture('auth_v1.json'))
store = auth_store.DataStore(hass)
data = await store.async_load()
assert len(data['groups']) == 2
system_group, family_group = data['groups']
assert system_group['system_generated'] is True
assert system_group['name'] == 'System'
assert family_group['system_generated'] is False
assert family_group['name'] == 'Family'
assert len(data['users']) == 2
owner, hassio = data['users']
assert owner['is_owner'] is True
assert owner['group_id'] == family_group['id']
assert hassio['is_owner'] is False
assert hassio['group_id'] == system_group['id']
assert len(data['refresh_tokens']) == 2
user_token, system_token = data['refresh_tokens']
assert user_token['token_type'] == models.TOKEN_TYPE_NORMAL
assert user_token['last_used_at'] is None
assert user_token['client_name'] is None
assert user_token['client_icon'] is None
assert user_token['last_used_ip'] is None
assert system_token['token_type'] == models.TOKEN_TYPE_SYSTEM
assert system_token['last_used_at'] is None
assert system_token['client_name'] is None
assert system_token['client_icon'] is None
assert system_token['last_used_ip'] is None

View file

@ -334,7 +334,7 @@ async def test_generating_system_user(hass):
manager = await auth.auth_manager_from_config(hass, [], []) manager = await auth.auth_manager_from_config(hass, [], [])
user = await manager.async_create_system_user('Hass.io') user = await manager.async_create_system_user('Hass.io')
token = await manager.async_create_refresh_token(user) token = await manager.async_create_refresh_token(user)
assert user.system_generated assert user.group.system_generated
assert token is not None assert token is not None
assert token.client_id is None assert token.client_id is None
@ -343,7 +343,7 @@ async def test_refresh_token_requires_client_for_user(hass):
"""Test create refresh token for a user with client_id.""" """Test create refresh token for a user with client_id."""
manager = await auth.auth_manager_from_config(hass, [], []) manager = await auth.auth_manager_from_config(hass, [], [])
user = MockUser().add_to_auth_manager(manager) user = MockUser().add_to_auth_manager(manager)
assert user.system_generated is False assert user.group.system_generated is False
with pytest.raises(ValueError): with pytest.raises(ValueError):
await manager.async_create_refresh_token(user) await manager.async_create_refresh_token(user)
@ -361,7 +361,7 @@ async def test_refresh_token_not_requires_client_for_system_user(hass):
"""Test create refresh token for a system user w/o client_id.""" """Test create refresh token for a system user w/o client_id."""
manager = await auth.auth_manager_from_config(hass, [], []) manager = await auth.auth_manager_from_config(hass, [], [])
user = await manager.async_create_system_user('Hass.io') user = await manager.async_create_system_user('Hass.io')
assert user.system_generated is True assert user.group.system_generated is True
with pytest.raises(ValueError): with pytest.raises(ValueError):
await manager.async_create_refresh_token(user, CLIENT_ID) await manager.async_create_refresh_token(user, CLIENT_ID)

View file

@ -355,11 +355,13 @@ class MockUser(auth_models.User):
'is_owner': is_owner, 'is_owner': is_owner,
'is_active': is_active, 'is_active': is_active,
'name': name, 'name': name,
'system_generated': system_generated, # Filled in when added to hass/auth manager
'group': None,
} }
if id is not None: if id is not None:
kwargs['id'] = id kwargs['id'] = id
super().__init__(**kwargs) super().__init__(**kwargs)
self._mock_system_generated = system_generated
def add_to_hass(self, hass): def add_to_hass(self, hass):
"""Test helper to add entry to hass.""" """Test helper to add entry to hass."""
@ -368,6 +370,14 @@ class MockUser(auth_models.User):
def add_to_auth_manager(self, auth_mgr): def add_to_auth_manager(self, auth_mgr):
"""Test helper to add entry to hass.""" """Test helper to add entry to hass."""
ensure_auth_manager_loaded(auth_mgr) ensure_auth_manager_loaded(auth_mgr)
if self._mock_system_generated:
group_id = auth_mgr._store._system_user_group_id
else:
group_id = auth_mgr._store._default_new_user_group_id
self.group = auth_mgr._store._groups[group_id]
auth_mgr._store._users[self.id] = self auth_mgr._store._users[self.id] = self
return self return self
@ -392,7 +402,7 @@ def ensure_auth_manager_loaded(auth_mgr):
"""Ensure an auth manager is considered loaded.""" """Ensure an auth manager is considered loaded."""
store = auth_mgr._store store = auth_mgr._store
if store._users is None: if store._users is None:
store._users = OrderedDict() store._set_defaults()
class MockModule: class MockModule:

View file

@ -83,6 +83,7 @@ async def test_list(hass, hass_ws_client):
'is_owner': True, 'is_owner': True,
'is_active': True, 'is_active': True,
'system_generated': False, 'system_generated': False,
'group_id': owner.group.id,
'credentials': [{'type': 'homeassistant'}] 'credentials': [{'type': 'homeassistant'}]
} }
assert data[1] == { assert data[1] == {
@ -91,6 +92,7 @@ async def test_list(hass, hass_ws_client):
'is_owner': False, 'is_owner': False,
'is_active': True, 'is_active': True,
'system_generated': True, 'system_generated': True,
'group_id': system.group.id,
'credentials': [], 'credentials': [],
} }
assert data[2] == { assert data[2] == {
@ -99,6 +101,7 @@ async def test_list(hass, hass_ws_client):
'is_owner': False, 'is_owner': False,
'is_active': False, 'is_active': False,
'system_generated': False, 'system_generated': False,
'group_id': inactive.group.id,
'credentials': [], 'credentials': [],
} }
@ -201,7 +204,7 @@ async def test_create(hass, hass_ws_client, hass_access_token):
assert user.name == data_user['name'] assert user.name == data_user['name']
assert user.is_active assert user.is_active
assert not user.is_owner assert not user.is_owner
assert not user.system_generated assert not user.group.system_generated
async def test_create_requires_owner(hass, hass_ws_client, hass_access_token): async def test_create_requires_owner(hass, hass_ws_client, hass_access_token):

View file

@ -105,7 +105,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock,
hass_storage[STORAGE_KEY]['data']['hassio_user'] hass_storage[STORAGE_KEY]['data']['hassio_user']
) )
assert hassio_user is not None assert hassio_user is not None
assert hassio_user.system_generated assert hassio_user.group.system_generated
for token in hassio_user.refresh_tokens.values(): for token in hassio_user.refresh_tokens.values():
if token.token == refresh_token: if token.token == refresh_token:
break break

51
tests/fixtures/auth_v1.json vendored Normal file
View file

@ -0,0 +1,51 @@
{
"data": {
"credentials": [],
"refresh_tokens": [
{
"access_token_expiration": 1800.0,
"client_id": "http://localhost:8123/",
"created_at": "2018-09-28T10:35:47.969734+00:00",
"id": "aaaaf7deb7ab454685d33c50c319ae7e",
"jwt_key": "some-jwt-key",
"token": "some-token",
"user_id": "74f25fa868d649bf83114b321f5f0256"
},
{
"access_token_expiration": 1800.0,
"client_id": "http://localhost:8123/",
"created_at": "2018-09-28T10:35:47.969734+00:00",
"id": "bbbbf7deb7ab454685d33c50c319ae7e",
"token": "some-token",
"user_id": "74f25fa868d649bf83114b321f5f0256"
},
{
"access_token_expiration": 1800.0,
"client_id": null,
"created_at": "2018-09-28T10:35:47.969734+00:00",
"id": "aaaaf7deb7ab454685d33c50c319ae7e",
"jwt_key": "some-jwt-key",
"token": "some-token",
"user_id": "74f25fa868d649bf83114b321f5f0256"
}
],
"users": [
{
"id": "74f25fa868d649cd83114b321f5f0256",
"is_active": true,
"is_owner": true,
"name": "Paulus",
"system_generated": false
},
{
"id": "b8e85152681d4611a13fb3ffe04b99f4",
"is_active": true,
"is_owner": false,
"name": "Hass.io",
"system_generated": true
}
]
},
"key": "auth",
"version": 1
}