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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue