Hass.io allow to reset password with CLI (#30755)

* Hass.io allow to reset passwort with CLI

* Add improvments

* fix comments

* fix lint

* Fix tests

* more tests

* Address comments

* sort imports

* fix test python37
This commit is contained in:
Pascal Vizeli 2020-01-14 23:49:56 +01:00 committed by GitHub
parent 9f62b58929
commit 4731f7c721
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 197 additions and 33 deletions

View file

@ -201,7 +201,7 @@ async def async_setup(hass, config):
require_admin=True, 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(_): async def push_config(_):
"""Push core config to Hass.io.""" """Push core config to Hass.io."""
@ -290,7 +290,7 @@ async def async_setup(hass, config):
async_setup_discovery_view(hass, hassio) async_setup_discovery_view(hass, hassio)
# Init auth Hass.io feature # Init auth Hass.io feature
async_setup_auth_view(hass) async_setup_auth_view(hass, user)
# Init ingress Hass.io feature # Init ingress Hass.io feature
async_setup_ingress_view(hass, host) async_setup_ingress_view(hass, host)

View file

@ -4,11 +4,16 @@ import logging
import os import os
from aiohttp import web from aiohttp import web
from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_exceptions import (
HTTPInternalServerError,
HTTPNotFound,
HTTPUnauthorized,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.components.http import HomeAssistantView 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.components.http.data_validator import RequestDataValidator
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -29,34 +34,42 @@ SCHEMA_API_AUTH = vol.Schema(
extra=vol.ALLOW_EXTRA, 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 @callback
def async_setup_auth_view(hass: HomeAssistantType): def async_setup_auth_view(hass: HomeAssistantType, user: User):
"""Auth setup.""" """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_auth)
hass.http.register_view(hassio_password_reset)
class HassIOAuth(HomeAssistantView): class HassIOBaseAuth(HomeAssistantView):
"""Hass.io view to handle base part.""" """Hass.io view to handle auth requests."""
name = "api:hassio_auth" def __init__(self, hass: HomeAssistantType, user: User):
url = "/api/hassio_auth"
def __init__(self, hass):
"""Initialize WebView.""" """Initialize WebView."""
self.hass = hass self.hass = hass
self.user = user
@RequestDataValidator(SCHEMA_API_AUTH) def _check_access(self, request: web.Request):
async def post(self, request, data): """Check if this call is from Supervisor."""
"""Handle new discovery requests.""" # Check caller IP
hassio_ip = os.environ["HASSIO"].split(":")[0] hassio_ip = os.environ["HASSIO"].split(":")[0]
if request[KEY_REAL_IP] != ip_address(hassio_ip): if request[KEY_REAL_IP] != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request[KEY_REAL_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]) # Check caller token
return web.Response(status=200) 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): def _get_provider(self):
"""Return Homeassistant auth provider.""" """Return Homeassistant auth provider."""
@ -67,6 +80,21 @@ class HassIOAuth(HomeAssistantView):
_LOGGER.error("Can't find Home Assistant auth.") _LOGGER.error("Can't find Home Assistant auth.")
raise HTTPNotFound() 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): async def _check_login(self, username, password):
"""Check User credentials.""" """Check User credentials."""
provider = self._get_provider() provider = self._get_provider()
@ -74,4 +102,31 @@ class HassIOAuth(HomeAssistantView):
try: try:
await provider.async_validate_login(username, password) await provider.async_validate_login(username, password)
except HomeAssistantError: 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()

View file

@ -130,7 +130,7 @@ class HassIO:
"ssl": CONF_SSL_CERTIFICATE in http_config, "ssl": CONF_SSL_CERTIFICATE in http_config,
"port": port, "port": port,
"watchdog": True, "watchdog": True,
"refresh_token": refresh_token, "refresh_token": refresh_token.token,
} }
if CONF_SERVER_HOST in http_config: if CONF_SERVER_HOST in http_config:

View file

@ -32,7 +32,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock):
with patch( with patch(
"homeassistant.components.hassio.HassIO.update_hass_api", "homeassistant.components.hassio.HassIO.update_hass_api",
return_value=mock_coro({"result": "ok"}), return_value=mock_coro({"result": "ok"}),
), patch( ) as hass_api, patch(
"homeassistant.components.hassio.HassIO.update_hass_timezone", "homeassistant.components.hassio.HassIO.update_hass_timezone",
return_value=mock_coro({"result": "ok"}), return_value=mock_coro({"result": "ok"}),
), patch( ), patch(
@ -42,6 +42,8 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock):
hass.state = CoreState.starting hass.state = CoreState.starting
hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {}))
return hass_api.call_args[0][1]
@pytest.fixture @pytest.fixture
def hassio_client(hassio_stubs, hass, hass_client): 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)) 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 @pytest.fixture
def hassio_handler(hass, aioclient_mock): def hassio_handler(hass, aioclient_mock):
"""Create mock hassio handler.""" """Create mock hassio handler."""

View file

@ -6,14 +6,14 @@ from homeassistant.exceptions import HomeAssistantError
from tests.common import mock_coro 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 .""" """Test no auth needed for ."""
with patch( with patch(
"homeassistant.auth.providers.homeassistant." "homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login", "HassAuthProvider.async_validate_login",
Mock(return_value=mock_coro()), Mock(return_value=mock_coro()),
) as mock_login: ) as mock_login:
resp = await hassio_client.post( resp = await hassio_client_supervisor.post(
"/api/hassio_auth", "/api/hassio_auth",
json={"username": "test", "password": "123456", "addon": "samba"}, 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") mock_login.assert_called_with("test", "123456")
async def test_login_error(hass, hassio_client): async def test_auth_fails_no_supervisor(hass, hassio_client):
"""Test no auth needed for error.""" """Test if only supervisor can access."""
with patch( with patch(
"homeassistant.auth.providers.homeassistant." "homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login", "HassAuthProvider.async_validate_login",
Mock(side_effect=HomeAssistantError()), Mock(return_value=mock_coro()),
) as mock_login: ) as mock_login:
resp = await hassio_client.post( resp = await hassio_client.post(
"/api/hassio_auth", "/api/hassio_auth",
@ -36,32 +36,66 @@ async def test_login_error(hass, hassio_client):
) )
# Check we got right response # 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") 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.""" """Test auth with no data -> error."""
with patch( with patch(
"homeassistant.auth.providers.homeassistant." "homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login", "HassAuthProvider.async_validate_login",
Mock(side_effect=HomeAssistantError()), Mock(side_effect=HomeAssistantError()),
) as mock_login: ) 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 # Check we got right response
assert resp.status == 400 assert resp.status == 400
assert not mock_login.called 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.""" """Test auth with no username in data -> error."""
with patch( with patch(
"homeassistant.auth.providers.homeassistant." "homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login", "HassAuthProvider.async_validate_login",
Mock(side_effect=HomeAssistantError()), Mock(side_effect=HomeAssistantError()),
) as mock_login: ) as mock_login:
resp = await hassio_client.post( resp = await hassio_client_supervisor.post(
"/api/hassio_auth", json={"password": "123456", "addon": "samba"} "/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 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.""" """Test auth with extra data."""
with patch( with patch(
"homeassistant.auth.providers.homeassistant." "homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login", "HassAuthProvider.async_validate_login",
Mock(return_value=mock_coro()), Mock(return_value=mock_coro()),
) as mock_login: ) as mock_login:
resp = await hassio_client.post( resp = await hassio_client_supervisor.post(
"/api/hassio_auth", "/api/hassio_auth",
json={ json={
"username": "test", "username": "test",
@ -90,3 +124,67 @@ async def test_login_success_extra(hass, hassio_client):
# Check we got right response # Check we got right response
assert resp.status == 200 assert resp.status == 200
mock_login.assert_called_with("test", "123456") 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