diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 28e06cc5d6a..f70e44cfa55 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -201,7 +201,7 @@ async def async_setup(hass, config): require_admin=True, ) - await hassio.update_hass_api(config.get("http", {}), refresh_token.token) + await hassio.update_hass_api(config.get("http", {}), refresh_token) async def push_config(_): """Push core config to Hass.io.""" @@ -290,7 +290,7 @@ async def async_setup(hass, config): async_setup_discovery_view(hass, hassio) # Init auth Hass.io feature - async_setup_auth_view(hass) + async_setup_auth_view(hass, user) # Init ingress Hass.io feature async_setup_ingress_view(hass, host) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 800801b4350..f8474e0fd24 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -4,11 +4,16 @@ import logging import os from aiohttp import web -from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound +from aiohttp.web_exceptions import ( + HTTPInternalServerError, + HTTPNotFound, + HTTPUnauthorized, +) import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.const import KEY_REAL_IP +from homeassistant.components.http.const import KEY_HASS_USER, KEY_REAL_IP from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -29,34 +34,42 @@ SCHEMA_API_AUTH = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SCHEMA_API_PASSWORD_RESET = vol.Schema( + {vol.Required(ATTR_USERNAME): cv.string, vol.Required(ATTR_PASSWORD): cv.string}, + extra=vol.ALLOW_EXTRA, +) + @callback -def async_setup_auth_view(hass: HomeAssistantType): +def async_setup_auth_view(hass: HomeAssistantType, user: User): """Auth setup.""" - hassio_auth = HassIOAuth(hass) + hassio_auth = HassIOAuth(hass, user) + hassio_password_reset = HassIOPasswordReset(hass, user) + hass.http.register_view(hassio_auth) + hass.http.register_view(hassio_password_reset) -class HassIOAuth(HomeAssistantView): - """Hass.io view to handle base part.""" +class HassIOBaseAuth(HomeAssistantView): + """Hass.io view to handle auth requests.""" - name = "api:hassio_auth" - url = "/api/hassio_auth" - - def __init__(self, hass): + def __init__(self, hass: HomeAssistantType, user: User): """Initialize WebView.""" self.hass = hass + self.user = user - @RequestDataValidator(SCHEMA_API_AUTH) - async def post(self, request, data): - """Handle new discovery requests.""" + def _check_access(self, request: web.Request): + """Check if this call is from Supervisor.""" + # Check caller IP hassio_ip = os.environ["HASSIO"].split(":")[0] if request[KEY_REAL_IP] != ip_address(hassio_ip): _LOGGER.error("Invalid auth request from %s", request[KEY_REAL_IP]) - raise HTTPForbidden() + raise HTTPUnauthorized() - await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD]) - return web.Response(status=200) + # Check caller token + if request[KEY_HASS_USER].id != self.user.id: + _LOGGER.error("Invalid auth request from %s", request[KEY_HASS_USER].name) + raise HTTPUnauthorized() def _get_provider(self): """Return Homeassistant auth provider.""" @@ -67,6 +80,21 @@ class HassIOAuth(HomeAssistantView): _LOGGER.error("Can't find Home Assistant auth.") raise HTTPNotFound() + +class HassIOAuth(HassIOBaseAuth): + """Hass.io view to handle auth requests.""" + + name = "api:hassio:auth" + url = "/api/hassio_auth" + + @RequestDataValidator(SCHEMA_API_AUTH) + async def post(self, request, data): + """Handle auth requests.""" + self._check_access(request) + + await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD]) + return web.Response(status=200) + async def _check_login(self, username, password): """Check User credentials.""" provider = self._get_provider() @@ -74,4 +102,31 @@ class HassIOAuth(HomeAssistantView): try: await provider.async_validate_login(username, password) except HomeAssistantError: - raise HTTPForbidden() from None + raise HTTPUnauthorized() from None + + +class HassIOPasswordReset(HassIOBaseAuth): + """Hass.io view to handle password reset requests.""" + + name = "api:hassio:auth:password:reset" + url = "/api/hassio_auth/password_reset" + + @RequestDataValidator(SCHEMA_API_PASSWORD_RESET) + async def post(self, request, data): + """Handle password reset requests.""" + self._check_access(request) + + await self._change_password(data[ATTR_USERNAME], data[ATTR_PASSWORD]) + return web.Response(status=200) + + async def _change_password(self, username, password): + """Check User credentials.""" + provider = self._get_provider() + + try: + await self.hass.async_add_executor_job( + provider.data.change_password, username, password + ) + await provider.data.async_save() + except HomeAssistantError: + raise HTTPInternalServerError() diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 5213443614c..e471bfae543 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -130,7 +130,7 @@ class HassIO: "ssl": CONF_SSL_CERTIFICATE in http_config, "port": port, "watchdog": True, - "refresh_token": refresh_token, + "refresh_token": refresh_token.token, } if CONF_SERVER_HOST in http_config: diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 091270c12c4..9a50da4ce41 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -32,7 +32,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): with patch( "homeassistant.components.hassio.HassIO.update_hass_api", return_value=mock_coro({"result": "ok"}), - ), patch( + ) as hass_api, patch( "homeassistant.components.hassio.HassIO.update_hass_timezone", return_value=mock_coro({"result": "ok"}), ), patch( @@ -42,6 +42,8 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): hass.state = CoreState.starting hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) + return hass_api.call_args[0][1] + @pytest.fixture def hassio_client(hassio_stubs, hass, hass_client): @@ -55,6 +57,15 @@ def hassio_noauth_client(hassio_stubs, hass, aiohttp_client): return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) +@pytest.fixture +async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs): + """Return an authenticated HTTP client.""" + access_token = hass.auth.async_create_access_token(hassio_stubs) + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {access_token}"}, + ) + + @pytest.fixture def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index c7fe3459e41..189273c5802 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -6,14 +6,14 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import mock_coro -async def test_login_success(hass, hassio_client): +async def test_auth_success(hass, hassio_client_supervisor): """Test no auth needed for .""" with patch( "homeassistant.auth.providers.homeassistant." "HassAuthProvider.async_validate_login", Mock(return_value=mock_coro()), ) as mock_login: - resp = await hassio_client.post( + resp = await hassio_client_supervisor.post( "/api/hassio_auth", json={"username": "test", "password": "123456", "addon": "samba"}, ) @@ -23,12 +23,12 @@ async def test_login_success(hass, hassio_client): mock_login.assert_called_with("test", "123456") -async def test_login_error(hass, hassio_client): - """Test no auth needed for error.""" +async def test_auth_fails_no_supervisor(hass, hassio_client): + """Test if only supervisor can access.""" with patch( "homeassistant.auth.providers.homeassistant." "HassAuthProvider.async_validate_login", - Mock(side_effect=HomeAssistantError()), + Mock(return_value=mock_coro()), ) as mock_login: resp = await hassio_client.post( "/api/hassio_auth", @@ -36,32 +36,66 @@ async def test_login_error(hass, hassio_client): ) # Check we got right response - assert resp.status == 403 + assert resp.status == 401 + assert not mock_login.called + + +async def test_auth_fails_no_auth(hass, hassio_noauth_client): + """Test if only supervisor can access.""" + with patch( + "homeassistant.auth.providers.homeassistant." + "HassAuthProvider.async_validate_login", + Mock(return_value=mock_coro()), + ) as mock_login: + resp = await hassio_noauth_client.post( + "/api/hassio_auth", + json={"username": "test", "password": "123456", "addon": "samba"}, + ) + + # Check we got right response + assert resp.status == 401 + assert not mock_login.called + + +async def test_login_error(hass, hassio_client_supervisor): + """Test no auth needed for error.""" + with patch( + "homeassistant.auth.providers.homeassistant." + "HassAuthProvider.async_validate_login", + Mock(side_effect=HomeAssistantError()), + ) as mock_login: + resp = await hassio_client_supervisor.post( + "/api/hassio_auth", + json={"username": "test", "password": "123456", "addon": "samba"}, + ) + + # Check we got right response + assert resp.status == 401 mock_login.assert_called_with("test", "123456") -async def test_login_no_data(hass, hassio_client): +async def test_login_no_data(hass, hassio_client_supervisor): """Test auth with no data -> error.""" with patch( "homeassistant.auth.providers.homeassistant." "HassAuthProvider.async_validate_login", Mock(side_effect=HomeAssistantError()), ) as mock_login: - resp = await hassio_client.post("/api/hassio_auth") + resp = await hassio_client_supervisor.post("/api/hassio_auth") # Check we got right response assert resp.status == 400 assert not mock_login.called -async def test_login_no_username(hass, hassio_client): +async def test_login_no_username(hass, hassio_client_supervisor): """Test auth with no username in data -> error.""" with patch( "homeassistant.auth.providers.homeassistant." "HassAuthProvider.async_validate_login", Mock(side_effect=HomeAssistantError()), ) as mock_login: - resp = await hassio_client.post( + resp = await hassio_client_supervisor.post( "/api/hassio_auth", json={"password": "123456", "addon": "samba"} ) @@ -70,14 +104,14 @@ async def test_login_no_username(hass, hassio_client): assert not mock_login.called -async def test_login_success_extra(hass, hassio_client): +async def test_login_success_extra(hass, hassio_client_supervisor): """Test auth with extra data.""" with patch( "homeassistant.auth.providers.homeassistant." "HassAuthProvider.async_validate_login", Mock(return_value=mock_coro()), ) as mock_login: - resp = await hassio_client.post( + resp = await hassio_client_supervisor.post( "/api/hassio_auth", json={ "username": "test", @@ -90,3 +124,67 @@ async def test_login_success_extra(hass, hassio_client): # Check we got right response assert resp.status == 200 mock_login.assert_called_with("test", "123456") + + +async def test_password_success(hass, hassio_client_supervisor): + """Test no auth needed for .""" + with patch( + "homeassistant.components.hassio.auth.HassIOPasswordReset._change_password", + Mock(return_value=mock_coro()), + ) as mock_change: + resp = await hassio_client_supervisor.post( + "/api/hassio_auth/password_reset", + json={"username": "test", "password": "123456"}, + ) + + # Check we got right response + assert resp.status == 200 + mock_change.assert_called_with("test", "123456") + + +async def test_password_fails_no_supervisor(hass, hassio_client): + """Test if only supervisor can access.""" + with patch( + "homeassistant.auth.providers.homeassistant.Data.async_save", + Mock(return_value=mock_coro()), + ) as mock_save: + resp = await hassio_client.post( + "/api/hassio_auth/password_reset", + json={"username": "test", "password": "123456"}, + ) + + # Check we got right response + assert resp.status == 401 + assert not mock_save.called + + +async def test_password_fails_no_auth(hass, hassio_noauth_client): + """Test if only supervisor can access.""" + with patch( + "homeassistant.auth.providers.homeassistant.Data.async_save", + Mock(return_value=mock_coro()), + ) as mock_save: + resp = await hassio_noauth_client.post( + "/api/hassio_auth/password_reset", + json={"username": "test", "password": "123456"}, + ) + + # Check we got right response + assert resp.status == 401 + assert not mock_save.called + + +async def test_password_no_user(hass, hassio_client_supervisor): + """Test no auth needed for .""" + with patch( + "homeassistant.auth.providers.homeassistant.Data.async_save", + Mock(return_value=mock_coro()), + ) as mock_save: + resp = await hassio_client_supervisor.post( + "/api/hassio_auth/password_reset", + json={"username": "test", "password": "123456"}, + ) + + # Check we got right response + assert resp.status == 500 + assert not mock_save.called