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:
parent
9f62b58929
commit
4731f7c721
5 changed files with 197 additions and 33 deletions
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue