239 lines
6.8 KiB
Python
239 lines
6.8 KiB
Python
"""Home Assistant auth provider."""
|
|
import base64
|
|
from collections import OrderedDict
|
|
import hashlib
|
|
import hmac
|
|
from typing import Dict # noqa: F401 pylint: disable=unused-import
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant import data_entry_flow
|
|
from homeassistant.const import CONF_ID
|
|
from homeassistant.core import callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
from homeassistant.auth.util import generate_secret
|
|
|
|
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS
|
|
|
|
STORAGE_VERSION = 1
|
|
STORAGE_KEY = 'auth_provider.homeassistant'
|
|
|
|
|
|
def _disallow_id(conf):
|
|
"""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)
|
|
|
|
|
|
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."""
|
|
|
|
def __init__(self, hass):
|
|
"""Initialize the user data store."""
|
|
self.hass = hass
|
|
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
|
self._data = None
|
|
|
|
async def async_load(self):
|
|
"""Load stored data."""
|
|
data = await self._store.async_load()
|
|
|
|
if data is None:
|
|
data = {
|
|
'salt': generate_secret(),
|
|
'users': []
|
|
}
|
|
|
|
self._data = data
|
|
|
|
@property
|
|
def users(self):
|
|
"""Return users."""
|
|
return self._data['users']
|
|
|
|
def validate_login(self, username: str, password: str) -> None:
|
|
"""Validate a username and password.
|
|
|
|
Raises InvalidAuth if auth invalid.
|
|
"""
|
|
hashed = self.hash_password(password)
|
|
|
|
found = None
|
|
|
|
# Compare all users to avoid timing attacks.
|
|
for user in self._data['users']:
|
|
if username == user['username']:
|
|
found = user
|
|
|
|
if found is None:
|
|
# Do one more compare to make timing the same as if user was found.
|
|
hmac.compare_digest(hashed, hashed)
|
|
raise InvalidAuth
|
|
|
|
if not hmac.compare_digest(hashed,
|
|
base64.b64decode(found['password'])):
|
|
raise InvalidAuth
|
|
|
|
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
|
"""Encode a password."""
|
|
hashed = hashlib.pbkdf2_hmac(
|
|
'sha512', password.encode(), self._data['salt'].encode(), 100000)
|
|
if for_storage:
|
|
hashed = base64.b64encode(hashed)
|
|
return hashed
|
|
|
|
def add_auth(self, username: str, password: str) -> None:
|
|
"""Add a new authenticated user/pass."""
|
|
if any(user['username'] == username for user in self.users):
|
|
raise InvalidUser
|
|
|
|
self.users.append({
|
|
'username': username,
|
|
'password': self.hash_password(password, True).decode(),
|
|
})
|
|
|
|
@callback
|
|
def async_remove_auth(self, username: str) -> None:
|
|
"""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)
|
|
|
|
def change_password(self, username: str, new_password: str) -> None:
|
|
"""Update the password.
|
|
|
|
Raises InvalidUser if user cannot be found.
|
|
"""
|
|
for user in self.users:
|
|
if user['username'] == username:
|
|
user['password'] = self.hash_password(
|
|
new_password, True).decode()
|
|
break
|
|
else:
|
|
raise InvalidUser
|
|
|
|
async def async_save(self):
|
|
"""Save data."""
|
|
await self._store.async_save(self._data)
|
|
|
|
|
|
@AUTH_PROVIDERS.register('homeassistant')
|
|
class HassAuthProvider(AuthProvider):
|
|
"""Auth provider based on a local storage of users in HASS config dir."""
|
|
|
|
DEFAULT_TITLE = 'Home Assistant Local'
|
|
|
|
data = None
|
|
|
|
async def async_initialize(self):
|
|
"""Initialize the auth provider."""
|
|
if self.data is not None:
|
|
return
|
|
|
|
self.data = Data(self.hass)
|
|
await self.data.async_load()
|
|
|
|
async def async_credential_flow(self):
|
|
"""Return a flow to login."""
|
|
return LoginFlow(self)
|
|
|
|
async def async_validate_login(self, username: str, password: str):
|
|
"""Helper to validate a username and password."""
|
|
if self.data is None:
|
|
await self.async_initialize()
|
|
|
|
await self.hass.async_add_executor_job(
|
|
self.data.validate_login, username, password)
|
|
|
|
async def async_get_or_create_credentials(self, flow_result):
|
|
"""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
|
|
})
|
|
|
|
async def async_user_meta_for_credentials(self, credentials):
|
|
"""Get extra info for this credential."""
|
|
return {
|
|
'name': credentials.data['username'],
|
|
'is_active': True,
|
|
}
|
|
|
|
async def async_will_remove_credentials(self, credentials):
|
|
"""When credentials get removed, also remove the auth."""
|
|
if self.data is None:
|
|
await self.async_initialize()
|
|
|
|
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
|
|
|
|
|
|
class LoginFlow(data_entry_flow.FlowHandler):
|
|
"""Handler for the login flow."""
|
|
|
|
def __init__(self, auth_provider):
|
|
"""Initialize the login flow."""
|
|
self._auth_provider = auth_provider
|
|
|
|
async def async_step_init(self, user_input=None):
|
|
"""Handle the step of the form."""
|
|
errors = {}
|
|
|
|
if user_input is not None:
|
|
try:
|
|
await self._auth_provider.async_validate_login(
|
|
user_input['username'], user_input['password'])
|
|
except InvalidAuth:
|
|
errors['base'] = 'invalid_auth'
|
|
|
|
if not errors:
|
|
return self.async_create_entry(
|
|
title=self._auth_provider.name,
|
|
data=user_input
|
|
)
|
|
|
|
schema = OrderedDict() # type: Dict[str, type]
|
|
schema['username'] = str
|
|
schema['password'] = str
|
|
|
|
return self.async_show_form(
|
|
step_id='init',
|
|
data_schema=vol.Schema(schema),
|
|
errors=errors,
|
|
)
|