From 9c53caae80fe8daca0678ed3b18616b7dedd5007 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 19 Oct 2020 16:38:28 +0200 Subject: [PATCH] Speedup HA core auth (#2144) * Speedup HA core auth * Add reset API call * use delete * Add complexe cache logic * Allow manage api to handle auth reset/cache * revert to only cache * add tests * ignore protected-access for this tests * fix comment --- supervisor/api/__init__.py | 6 ++- supervisor/api/auth.py | 5 +++ supervisor/api/security.py | 1 + supervisor/auth.py | 35 ++++++++++++++-- tests/conftest.py | 3 ++ tests/test_auth.py | 86 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/test_auth.py diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 57ea4a680..ed49bd525 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -221,7 +221,11 @@ class RestAPI(CoreSysAttributes): api_auth.coresys = self.coresys self.webapp.add_routes( - [web.post("/auth", api_auth.auth), web.post("/auth/reset", api_auth.reset)] + [ + web.post("/auth", api_auth.auth), + web.post("/auth/reset", api_auth.reset), + web.delete("/auth/cache", api_auth.cache), + ] ) def _register_supervisor(self) -> None: diff --git a/supervisor/api/auth.py b/supervisor/api/auth.py index 7143fbbad..9e5c0feab 100644 --- a/supervisor/api/auth.py +++ b/supervisor/api/auth.py @@ -86,3 +86,8 @@ class APIAuth(CoreSysAttributes): await asyncio.shield( self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD]) ) + + @api_process + async def cache(self, request: web.Request) -> None: + """Process cache reset request.""" + self.sys_auth.reset_data() diff --git a/supervisor/api/security.py b/supervisor/api/security.py index 7f6d79ca9..09905aa15 100644 --- a/supervisor/api/security.py +++ b/supervisor/api/security.py @@ -82,6 +82,7 @@ ADDONS_ROLE_ACCESS = { r"^(?:" r"|/addons(?:/[^/]+/(?!security).+|/reload)?" r"|/audio/.+" + r"|/auth/cache" r"|/cli/.+" r"|/core/.+" r"|/dns/.+" diff --git a/supervisor/auth.py b/supervisor/auth.py index 8d24bd1b5..bff19438b 100644 --- a/supervisor/auth.py +++ b/supervisor/auth.py @@ -1,6 +1,8 @@ """Manage SSO for Add-ons with Home Assistant user.""" +import asyncio import hashlib import logging +from typing import Dict, Optional from .addons.addon import Addon from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, FILE_HASSIO_AUTH @@ -20,16 +22,21 @@ class Auth(JsonConfig, CoreSysAttributes): super().__init__(FILE_HASSIO_AUTH, SCHEMA_AUTH_CONFIG) self.coresys: CoreSys = coresys - def _check_cache(self, username: str, password: str) -> bool: + self._running: Dict[str, asyncio.Task] = {} + + def _check_cache(self, username: str, password: str) -> Optional[bool]: """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 - - _LOGGER.warning("Username '%s' not is in cache", username) return False def _update_cache(self, username: str, password: str) -> None: @@ -61,11 +68,29 @@ class Auth(JsonConfig, CoreSysAttributes): raise AuthError() _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.debug("Home Assistant not running, checking cache") - return self._check_cache(username, password) + 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", @@ -87,6 +112,8 @@ class Auth(JsonConfig, CoreSysAttributes): return False except HomeAssistantAPIError: _LOGGER.error("Can't request auth on Home Assistant!") + finally: + self._running.pop(username, None) raise AuthError() diff --git a/tests/conftest.py b/tests/conftest.py index aa77654e7..622067851 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,6 +107,9 @@ async def coresys(loop, docker, dbus, network_manager, aiohttp_client) -> CoreSy # Mock save json coresys_obj.ingress.save_data = MagicMock() + coresys_obj.auth.save_data = MagicMock() + coresys_obj.updater.save_data = MagicMock() + coresys_obj.config.save_data = MagicMock() # Mock test client coresys_obj.arch._default_arch = "amd64" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 000000000..0ccde0e76 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,86 @@ +"""Test auth object.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# pylint: disable=protected-access + + +@pytest.fixture(name="mock_auth_backend", autouse=True) +def mock_auth_backend_fixture(coresys): + """Fix auth backend request.""" + mock_auth_backend = AsyncMock() + coresys.auth._backend_login = mock_auth_backend + + yield mock_auth_backend + + +@pytest.fixture(name="mock_api_state", autouse=True) +def mock_api_state_fixture(coresys): + """Fix auth backend request.""" + mock_api_state = AsyncMock() + coresys.homeassistant.api.check_api_state = mock_api_state + + yield mock_api_state + + +@pytest.mark.asyncio +async def test_auth_request_with_backend(coresys, mock_auth_backend, mock_api_state): + """Make simple auth request.""" + + addon = MagicMock() + mock_auth_backend.return_value = True + mock_api_state.return_value = True + + assert await coresys.auth.check_login(addon, "username", "password") + assert mock_auth_backend.called + + +@pytest.mark.asyncio +async def test_auth_request_without_backend(coresys, mock_auth_backend, mock_api_state): + """Make simple auth without request.""" + + addon = MagicMock() + mock_auth_backend.return_value = True + mock_api_state.return_value = False + + assert not await coresys.auth.check_login(addon, "username", "password") + assert not mock_auth_backend.called + + +@pytest.mark.asyncio +async def test_auth_request_without_backend_cache( + coresys, mock_auth_backend, mock_api_state +): + """Make simple auth without request.""" + + addon = MagicMock() + mock_auth_backend.return_value = True + mock_api_state.return_value = False + + coresys.auth._update_cache("username", "password") + + assert await coresys.auth.check_login(addon, "username", "password") + assert not mock_auth_backend.called + + +@pytest.mark.asyncio +async def test_auth_request_with_backend_cache_update( + coresys, mock_auth_backend, mock_api_state +): + """Make simple auth without request and cache update.""" + + addon = MagicMock() + mock_auth_backend.return_value = False + mock_api_state.return_value = True + + coresys.auth._update_cache("username", "password") + + assert await coresys.auth.check_login(addon, "username", "password") + + await asyncio.sleep(0) + + assert mock_auth_backend.called + coresys.auth._dismatch_cache("username", "password") + assert not await coresys.auth.check_login(addon, "username", "password")