2018-05-10 18:09:22 +00:00
|
|
|
"""Home Assistant auth provider."""
|
|
|
|
import base64
|
|
|
|
from collections import OrderedDict
|
|
|
|
import hashlib
|
|
|
|
import hmac
|
2018-08-21 18:03:38 +00:00
|
|
|
from typing import Any, Dict, List, Optional, cast
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-08-26 20:50:31 +00:00
|
|
|
import bcrypt
|
2018-05-10 18:09:22 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2018-07-13 13:31:20 +00:00
|
|
|
from homeassistant.const import CONF_ID
|
2018-08-16 20:25:41 +00:00
|
|
|
from homeassistant.core import callback, HomeAssistant
|
2018-05-10 18:09:22 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2018-08-26 20:50:31 +00:00
|
|
|
from homeassistant.util.async_ import run_coroutine_threadsafe
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-08-21 18:03:38 +00:00
|
|
|
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
2018-08-26 20:50:31 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
from ..models import Credentials, UserMeta
|
2018-08-21 18:03:38 +00:00
|
|
|
from ..util import generate_secret
|
|
|
|
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-06-29 04:02:45 +00:00
|
|
|
STORAGE_VERSION = 1
|
|
|
|
STORAGE_KEY = 'auth_provider.homeassistant'
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-07-13 13:31:20 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Disallow ID in config."""
|
|
|
|
if CONF_ID in conf:
|
|
|
|
raise vol.Invalid(
|
|
|
|
'ID is not allowed for the homeassistant auth provider.')
|
|
|
|
|
|
|
|
return conf
|
|
|
|
|
|
|
|
|
|
|
|
CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id)
|
2018-05-10 18:09:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
class InvalidAuth(HomeAssistantError):
|
|
|
|
"""Raised when we encounter invalid authentication."""
|
|
|
|
|
|
|
|
|
|
|
|
class InvalidUser(HomeAssistantError):
|
|
|
|
"""Raised when invalid user is specified.
|
|
|
|
|
|
|
|
Will not be raised when validating authentication.
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
class Data:
|
|
|
|
"""Hold the user data."""
|
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Initialize the user data store."""
|
2018-06-29 04:02:45 +00:00
|
|
|
self.hass = hass
|
|
|
|
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
2018-08-16 20:25:41 +00:00
|
|
|
self._data = None # type: Optional[Dict[str, Any]]
|
2018-06-29 04:02:45 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_load(self) -> None:
|
2018-06-29 04:02:45 +00:00
|
|
|
"""Load stored data."""
|
|
|
|
data = await self._store.async_load()
|
|
|
|
|
2018-05-10 18:09:22 +00:00
|
|
|
if data is None:
|
|
|
|
data = {
|
2018-07-13 09:43:08 +00:00
|
|
|
'salt': generate_secret(),
|
2018-05-10 18:09:22 +00:00
|
|
|
'users': []
|
|
|
|
}
|
2018-06-29 04:02:45 +00:00
|
|
|
|
2018-05-10 18:09:22 +00:00
|
|
|
self._data = data
|
|
|
|
|
|
|
|
@property
|
2018-08-16 20:25:41 +00:00
|
|
|
def users(self) -> List[Dict[str, str]]:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Return users."""
|
2018-08-16 20:25:41 +00:00
|
|
|
return self._data['users'] # type: ignore
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-07-25 09:36:03 +00:00
|
|
|
def validate_login(self, username: str, password: str) -> None:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Validate a username and password.
|
|
|
|
|
|
|
|
Raises InvalidAuth if auth invalid.
|
|
|
|
"""
|
2018-08-26 20:50:31 +00:00
|
|
|
dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO'
|
2018-05-10 18:09:22 +00:00
|
|
|
found = None
|
|
|
|
|
|
|
|
# Compare all users to avoid timing attacks.
|
2018-08-16 20:25:41 +00:00
|
|
|
for user in self.users:
|
2018-05-10 18:09:22 +00:00
|
|
|
if username == user['username']:
|
|
|
|
found = user
|
|
|
|
|
|
|
|
if found is None:
|
2018-08-26 20:50:31 +00:00
|
|
|
# check a hash to make timing the same as if user was found
|
|
|
|
bcrypt.checkpw(b'foo',
|
|
|
|
dummy)
|
2018-05-10 18:09:22 +00:00
|
|
|
raise InvalidAuth
|
|
|
|
|
2018-08-26 20:50:31 +00:00
|
|
|
user_hash = base64.b64decode(found['password'])
|
|
|
|
|
|
|
|
# if the hash is not a bcrypt hash...
|
|
|
|
# provide a transparant upgrade for old pbkdf2 hash format
|
|
|
|
if not (user_hash.startswith(b'$2a$')
|
|
|
|
or user_hash.startswith(b'$2b$')
|
|
|
|
or user_hash.startswith(b'$2x$')
|
|
|
|
or user_hash.startswith(b'$2y$')):
|
|
|
|
# IMPORTANT! validate the login, bail if invalid
|
|
|
|
hashed = self.legacy_hash_password(password)
|
|
|
|
if not hmac.compare_digest(hashed, user_hash):
|
|
|
|
raise InvalidAuth
|
|
|
|
# then re-hash the valid password with bcrypt
|
|
|
|
self.change_password(found['username'], password)
|
|
|
|
run_coroutine_threadsafe(
|
|
|
|
self.async_save(), self.hass.loop
|
|
|
|
).result()
|
|
|
|
user_hash = base64.b64decode(found['password'])
|
|
|
|
|
|
|
|
# bcrypt.checkpw is timing-safe
|
|
|
|
if not bcrypt.checkpw(password.encode(),
|
|
|
|
user_hash):
|
2018-05-10 18:09:22 +00:00
|
|
|
raise InvalidAuth
|
|
|
|
|
2018-08-26 20:50:31 +00:00
|
|
|
def legacy_hash_password(self, password: str,
|
|
|
|
for_storage: bool = False) -> bytes:
|
|
|
|
"""LEGACY password encoding."""
|
|
|
|
# We're no longer storing salts in data, but if one exists we
|
|
|
|
# should be able to retrieve it.
|
2018-08-16 20:25:41 +00:00
|
|
|
salt = self._data['salt'].encode() # type: ignore
|
|
|
|
hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000)
|
2018-05-10 18:09:22 +00:00
|
|
|
if for_storage:
|
2018-07-25 09:36:03 +00:00
|
|
|
hashed = base64.b64encode(hashed)
|
2018-05-10 18:09:22 +00:00
|
|
|
return hashed
|
|
|
|
|
2018-08-26 20:50:31 +00:00
|
|
|
# pylint: disable=no-self-use
|
|
|
|
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
|
|
|
"""Encode a password."""
|
|
|
|
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \
|
|
|
|
# type: bytes
|
|
|
|
if for_storage:
|
|
|
|
hashed = base64.b64encode(hashed)
|
|
|
|
return hashed
|
|
|
|
|
2018-07-25 09:36:03 +00:00
|
|
|
def add_auth(self, username: str, password: str) -> None:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Add a new authenticated user/pass."""
|
2018-05-10 18:09:22 +00:00
|
|
|
if any(user['username'] == username for user in self.users):
|
|
|
|
raise InvalidUser
|
|
|
|
|
|
|
|
self.users.append({
|
|
|
|
'username': username,
|
2018-07-25 09:36:03 +00:00
|
|
|
'password': self.hash_password(password, True).decode(),
|
2018-05-10 18:09:22 +00:00
|
|
|
})
|
|
|
|
|
2018-07-13 13:31:20 +00:00
|
|
|
@callback
|
2018-07-25 09:36:03 +00:00
|
|
|
def async_remove_auth(self, username: str) -> None:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Remove authentication."""
|
|
|
|
index = None
|
|
|
|
for i, user in enumerate(self.users):
|
|
|
|
if user['username'] == username:
|
|
|
|
index = i
|
|
|
|
break
|
|
|
|
|
|
|
|
if index is None:
|
|
|
|
raise InvalidUser
|
|
|
|
|
|
|
|
self.users.pop(index)
|
|
|
|
|
2018-07-25 09:36:03 +00:00
|
|
|
def change_password(self, username: str, new_password: str) -> None:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Update the password.
|
2018-05-10 18:09:22 +00:00
|
|
|
|
|
|
|
Raises InvalidUser if user cannot be found.
|
|
|
|
"""
|
|
|
|
for user in self.users:
|
|
|
|
if user['username'] == username:
|
2018-07-25 09:36:03 +00:00
|
|
|
user['password'] = self.hash_password(
|
|
|
|
new_password, True).decode()
|
2018-05-10 18:09:22 +00:00
|
|
|
break
|
|
|
|
else:
|
|
|
|
raise InvalidUser
|
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_save(self) -> None:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Save data."""
|
2018-06-29 04:02:45 +00:00
|
|
|
await self._store.async_save(self._data)
|
2018-05-10 18:09:22 +00:00
|
|
|
|
|
|
|
|
2018-07-13 09:43:08 +00:00
|
|
|
@AUTH_PROVIDERS.register('homeassistant')
|
|
|
|
class HassAuthProvider(AuthProvider):
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Auth provider based on a local storage of users in HASS config dir."""
|
|
|
|
|
|
|
|
DEFAULT_TITLE = 'Home Assistant Local'
|
|
|
|
|
2018-07-13 13:31:20 +00:00
|
|
|
data = None
|
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_initialize(self) -> None:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Initialize the auth provider."""
|
2018-07-17 08:49:15 +00:00
|
|
|
if self.data is not None:
|
|
|
|
return
|
|
|
|
|
2018-07-13 13:31:20 +00:00
|
|
|
self.data = Data(self.hass)
|
|
|
|
await self.data.async_load()
|
|
|
|
|
2018-08-21 18:03:38 +00:00
|
|
|
async def async_login_flow(
|
|
|
|
self, context: Optional[Dict]) -> LoginFlow:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Return a flow to login."""
|
2018-08-21 18:03:38 +00:00
|
|
|
return HassLoginFlow(self)
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_validate_login(self, username: str, password: str) -> None:
|
2018-08-24 08:28:43 +00:00
|
|
|
"""Validate a username and password."""
|
2018-07-13 13:31:20 +00:00
|
|
|
if self.data is None:
|
|
|
|
await self.async_initialize()
|
2018-08-16 20:25:41 +00:00
|
|
|
assert self.data is not None
|
2018-07-13 13:31:20 +00:00
|
|
|
|
2018-06-29 04:02:45 +00:00
|
|
|
await self.hass.async_add_executor_job(
|
2018-07-13 13:31:20 +00:00
|
|
|
self.data.validate_login, username, password)
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_get_or_create_credentials(
|
|
|
|
self, flow_result: Dict[str, str]) -> Credentials:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Get credentials based on the flow result."""
|
|
|
|
username = flow_result['username']
|
|
|
|
|
|
|
|
for credential in await self.async_credentials():
|
|
|
|
if credential.data['username'] == username:
|
|
|
|
return credential
|
|
|
|
|
|
|
|
# Create new credentials.
|
|
|
|
return self.async_create_credentials({
|
|
|
|
'username': username
|
|
|
|
})
|
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_user_meta_for_credentials(
|
|
|
|
self, credentials: Credentials) -> UserMeta:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""Get extra info for this credential."""
|
2018-08-16 20:25:41 +00:00
|
|
|
return UserMeta(name=credentials.data['username'], is_active=True)
|
2018-07-13 13:31:20 +00:00
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_will_remove_credentials(
|
|
|
|
self, credentials: Credentials) -> None:
|
2018-07-13 13:31:20 +00:00
|
|
|
"""When credentials get removed, also remove the auth."""
|
|
|
|
if self.data is None:
|
|
|
|
await self.async_initialize()
|
2018-08-16 20:25:41 +00:00
|
|
|
assert self.data is not None
|
2018-07-13 13:31:20 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
self.data.async_remove_auth(credentials.data['username'])
|
|
|
|
await self.data.async_save()
|
|
|
|
except InvalidUser:
|
|
|
|
# Can happen if somehow we didn't clean up a credential
|
|
|
|
pass
|
|
|
|
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-08-21 18:03:38 +00:00
|
|
|
class HassLoginFlow(LoginFlow):
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Handler for the login flow."""
|
|
|
|
|
2018-08-16 20:25:41 +00:00
|
|
|
async def async_step_init(
|
2018-08-17 18:22:49 +00:00
|
|
|
self, user_input: Optional[Dict[str, str]] = None) \
|
|
|
|
-> Dict[str, Any]:
|
2018-05-10 18:09:22 +00:00
|
|
|
"""Handle the step of the form."""
|
|
|
|
errors = {}
|
|
|
|
|
|
|
|
if user_input is not None:
|
|
|
|
try:
|
2018-08-21 18:03:38 +00:00
|
|
|
await cast(HassAuthProvider, self._auth_provider)\
|
|
|
|
.async_validate_login(user_input['username'],
|
|
|
|
user_input['password'])
|
2018-05-10 18:09:22 +00:00
|
|
|
except InvalidAuth:
|
|
|
|
errors['base'] = 'invalid_auth'
|
|
|
|
|
|
|
|
if not errors:
|
2018-08-21 18:03:38 +00:00
|
|
|
user_input.pop('password')
|
|
|
|
return await self.async_finish(user_input)
|
2018-05-10 18:09:22 +00:00
|
|
|
|
2018-07-25 09:36:03 +00:00
|
|
|
schema = OrderedDict() # type: Dict[str, type]
|
2018-05-10 18:09:22 +00:00
|
|
|
schema['username'] = str
|
|
|
|
schema['password'] = str
|
|
|
|
|
|
|
|
return self.async_show_form(
|
|
|
|
step_id='init',
|
|
|
|
data_schema=vol.Schema(schema),
|
|
|
|
errors=errors,
|
|
|
|
)
|