core/homeassistant/auth/providers/homeassistant.py

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,
)