mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-10 20:29:37 +00:00

* Simplify ensure_access_token Make the caller of ensure_access_token responsible for connection error handling. This is especially useful for API connection checks, as it avoids an extra call to the API (if we fail to connect when refreshing the token there is no point in calling the API to check if it is up). Document the change in the docstring. Also avoid the overhead of creating a Job object. We can simply use an asyncio.Lock() to ensure only one coroutine is refreshing the token at a time. This also avoids Job interference in Exception handling. * Remove check_port from API checks Remove check_port usage from Home Assistant API connection checks. Simply rely on errors raised from actual connection attempts. During a Supervisor startup when Home Assistant Core is running (e.g. after a Supervisor update) we make about 10 successful API checks. The old code path did a port check and then a connection check, causing two socket creation. The new code without the separate port check safes 10 socket creations per startup (the aiohttp connections are reused, hence do not cause only one socket creation). * Log API exceptions on call site Since make_request is no longer logging API exceptions on its own, we need to log them where we call make_request. This approach gives the user more context about what Supervisor was trying to do when the error happened. * Avoid unnecessary nesting * Improve error when ingress panel update fails * Add comment about fast path
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
"""Manage SSO for Add-ons with Home Assistant user."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
from typing import Any, TypedDict, cast
|
|
|
|
from .addons.addon import Addon
|
|
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
|
|
from .coresys import CoreSys, CoreSysAttributes
|
|
from .exceptions import (
|
|
AuthError,
|
|
AuthListUsersError,
|
|
AuthPasswordResetError,
|
|
HomeAssistantAPIError,
|
|
HomeAssistantWSError,
|
|
)
|
|
from .utils.common import FileConfiguration
|
|
from .validate import SCHEMA_AUTH_CONFIG
|
|
|
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BackendAuthRequest(TypedDict):
|
|
"""Model for a backend auth request.
|
|
|
|
https://github.com/home-assistant/core/blob/ed9503324d9d255e6fb077f1614fb6d55800f389/homeassistant/components/hassio/auth.py#L66-L73
|
|
"""
|
|
|
|
username: str
|
|
password: str
|
|
addon: str
|
|
|
|
|
|
class Auth(FileConfiguration, CoreSysAttributes):
|
|
"""Manage SSO for Add-ons with Home Assistant user."""
|
|
|
|
def __init__(self, coresys: CoreSys) -> None:
|
|
"""Initialize updater."""
|
|
super().__init__(FILE_HASSIO_AUTH, SCHEMA_AUTH_CONFIG)
|
|
self.coresys: CoreSys = coresys
|
|
|
|
self._running: dict[str, asyncio.Task] = {}
|
|
|
|
def _check_cache(self, username: str, password: str) -> bool | None:
|
|
"""Check password in cache."""
|
|
username_h = self._rehash(username)
|
|
password_h = self._rehash(password, username)
|
|
|
|
if username_h not in self._data:
|
|
_LOGGER.debug("Username '%s' not is in cache", username)
|
|
return None
|
|
|
|
# check cache
|
|
if self._data.get(username_h) == password_h:
|
|
_LOGGER.debug("Username '%s' is in cache", username)
|
|
return True
|
|
return False
|
|
|
|
async def _update_cache(self, username: str, password: str) -> None:
|
|
"""Cache a username, password."""
|
|
username_h = self._rehash(username)
|
|
password_h = self._rehash(password, username)
|
|
|
|
if self._data.get(username_h) == password_h:
|
|
return
|
|
|
|
self._data[username_h] = password_h
|
|
await self.save_data()
|
|
|
|
async def _dismatch_cache(self, username: str, password: str) -> None:
|
|
"""Remove user from cache."""
|
|
username_h = self._rehash(username)
|
|
password_h = self._rehash(password, username)
|
|
|
|
if self._data.get(username_h) != password_h:
|
|
return
|
|
|
|
self._data.pop(username_h, None)
|
|
await self.save_data()
|
|
|
|
async def check_login(
|
|
self, addon: Addon, username: str | None, password: str | None
|
|
) -> bool:
|
|
"""Check username login."""
|
|
if password is None:
|
|
raise AuthError("None as password is not supported!", _LOGGER.error)
|
|
if username is None:
|
|
raise AuthError("None as username is not supported!", _LOGGER.error)
|
|
|
|
_LOGGER.info("Auth request from '%s' for '%s'", addon.slug, username)
|
|
|
|
# Get from cache
|
|
cache_hit = self._check_cache(username, password)
|
|
|
|
# Check API state
|
|
if not await self.sys_homeassistant.api.check_api_state():
|
|
_LOGGER.info("Home Assistant not running, checking cache")
|
|
return cache_hit is True
|
|
|
|
# No cache hit
|
|
if cache_hit is None:
|
|
return await self._backend_login(addon, username, password)
|
|
|
|
# Home Assistant Core take over 1-2sec to validate it
|
|
# Let's use the cache and update the cache in background
|
|
if username not in self._running:
|
|
self._running[username] = self.sys_create_task(
|
|
self._backend_login(addon, username, password)
|
|
)
|
|
|
|
return cache_hit
|
|
|
|
async def _backend_login(self, addon: Addon, username: str, password: str) -> bool:
|
|
"""Check username login on core."""
|
|
try:
|
|
async with self.sys_homeassistant.api.make_request(
|
|
"post",
|
|
"api/hassio_auth",
|
|
json=cast(
|
|
dict[str, Any],
|
|
BackendAuthRequest(
|
|
username=username, password=password, addon=addon.slug
|
|
),
|
|
),
|
|
) as req:
|
|
if req.status == 200:
|
|
_LOGGER.info("Successful login for '%s'", username)
|
|
await self._update_cache(username, password)
|
|
return True
|
|
|
|
_LOGGER.warning("Unauthorized login for '%s'", username)
|
|
await self._dismatch_cache(username, password)
|
|
return False
|
|
except HomeAssistantAPIError as err:
|
|
_LOGGER.error("Can't request auth on Home Assistant: %s", err)
|
|
finally:
|
|
self._running.pop(username, None)
|
|
|
|
raise AuthError()
|
|
|
|
async def change_password(self, username: str, password: str) -> None:
|
|
"""Change user password login."""
|
|
try:
|
|
async with self.sys_homeassistant.api.make_request(
|
|
"post",
|
|
"api/hassio_auth/password_reset",
|
|
json={ATTR_USERNAME: username, ATTR_PASSWORD: password},
|
|
) as req:
|
|
if req.status == 200:
|
|
_LOGGER.info("Successful password reset for '%s'", username)
|
|
return
|
|
|
|
_LOGGER.warning("The user '%s' is not registered", username)
|
|
except HomeAssistantAPIError as err:
|
|
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
|
|
|
|
raise AuthPasswordResetError()
|
|
|
|
async def list_users(self) -> list[dict[str, Any]]:
|
|
"""List users on the Home Assistant instance."""
|
|
try:
|
|
users: (
|
|
list[dict[str, Any]] | None
|
|
) = await self.sys_homeassistant.websocket.async_send_command(
|
|
{ATTR_TYPE: "config/auth/list"}
|
|
)
|
|
except HomeAssistantWSError as err:
|
|
raise AuthListUsersError(
|
|
f"Can't request listing users on Home Assistant: {err}", _LOGGER.error
|
|
) from err
|
|
|
|
if users is not None:
|
|
return users
|
|
raise AuthListUsersError(
|
|
"Can't request listing users on Home Assistant!", _LOGGER.error
|
|
)
|
|
|
|
@staticmethod
|
|
def _rehash(value: str, salt2: str = "") -> str:
|
|
"""Rehash a value."""
|
|
for idx in range(1, 20):
|
|
value = hashlib.sha256(f"{value}{idx}{salt2}".encode()).hexdigest()
|
|
return value
|