diff --git a/API.md b/API.md index bb34d7aa4..61fc65566 100644 --- a/API.md +++ b/API.md @@ -828,3 +828,12 @@ We support: - Json `{ "user|name": "...", "password": "..." }` - application/x-www-form-urlencoded `user|name=...&password=...` - BasicAuth + +* POST `/auth/reset` + +```json +{ + "username": "xy", + "password": "new-password" +} +``` diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 43b5267f7..864fd5419 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -121,7 +121,9 @@ class RestAPI(CoreSysAttributes): api_auth = APIAuth() api_auth.coresys = self.coresys - self.webapp.add_routes([web.post("/auth", api_auth.auth)]) + self.webapp.add_routes( + [web.post("/auth", api_auth.auth), web.post("/auth/reset", api_auth.reset)] + ) def _register_supervisor(self) -> None: """Register Supervisor functions.""" diff --git a/hassio/api/auth.py b/hassio/api/auth.py index 6c33aab63..5a645f129 100644 --- a/hassio/api/auth.py +++ b/hassio/api/auth.py @@ -1,22 +1,39 @@ """Init file for Hass.io auth/SSO RESTful API.""" +import asyncio import logging +from typing import Dict -from aiohttp import BasicAuth +from aiohttp import BasicAuth, web +from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE from aiohttp.web_exceptions import HTTPUnauthorized -from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION, WWW_AUTHENTICATE +import voluptuous as vol -from .utils import api_process -from ..const import REQUEST_FROM, CONTENT_TYPE_JSON, CONTENT_TYPE_URL +from ..addons.addon import Addon +from ..const import ( + ATTR_PASSWORD, + ATTR_USERNAME, + CONTENT_TYPE_JSON, + CONTENT_TYPE_URL, + REQUEST_FROM, +) from ..coresys import CoreSysAttributes from ..exceptions import APIForbidden +from .utils import api_process, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) +SCHEMA_PASSWORD_RESET = vol.Schema( + { + vol.Required(ATTR_USERNAME): vol.Coerce(str), + vol.Required(ATTR_PASSWORD): vol.Coerce(str), + } +) + class APIAuth(CoreSysAttributes): """Handle RESTful API for auth functions.""" - def _process_basic(self, request, addon): + def _process_basic(self, request: web.Request, addon: Addon) -> bool: """Process login request with basic auth. Return a coroutine. @@ -24,7 +41,9 @@ class APIAuth(CoreSysAttributes): auth = BasicAuth.decode(request.headers[AUTHORIZATION]) return self.sys_auth.check_login(addon, auth.login, auth.password) - def _process_dict(self, request, addon, data): + def _process_dict( + self, request: web.Request, addon: Addon, data: Dict[str, str] + ) -> bool: """Process login with dict data. Return a coroutine. @@ -35,7 +54,7 @@ class APIAuth(CoreSysAttributes): return self.sys_auth.check_login(addon, username, password) @api_process - async def auth(self, request): + async def auth(self, request: web.Request) -> bool: """Process login request.""" addon = request[REQUEST_FROM] @@ -59,3 +78,11 @@ class APIAuth(CoreSysAttributes): raise HTTPUnauthorized( headers={WWW_AUTHENTICATE: 'Basic realm="Hass.io Authentication"'} ) + + @api_process + async def reset(self, request: web.Request) -> None: + """Process reset password request.""" + body: Dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request) + await asyncio.shield( + self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD]) + ) diff --git a/hassio/auth.py b/hassio/auth.py index 5d932a17a..06797a735 100644 --- a/hassio/auth.py +++ b/hassio/auth.py @@ -1,12 +1,13 @@ """Manage SSO for Add-ons with Home Assistant user.""" -import logging import hashlib +import logging -from .const import FILE_HASSIO_AUTH, ATTR_PASSWORD, ATTR_USERNAME, ATTR_ADDON -from .coresys import CoreSysAttributes +from .addons.addon import Addon +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, FILE_HASSIO_AUTH +from .coresys import CoreSys, CoreSysAttributes +from .exceptions import AuthError, AuthPasswordResetError, HomeAssistantAPIError from .utils.json import JsonConfig from .validate import SCHEMA_AUTH_CONFIG -from .exceptions import AuthError, HomeAssistantAPIError _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -14,15 +15,15 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) class Auth(JsonConfig, CoreSysAttributes): """Manage SSO for Add-ons with Home Assistant user.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys) -> None: """Initialize updater.""" super().__init__(FILE_HASSIO_AUTH, SCHEMA_AUTH_CONFIG) - self.coresys = coresys + self.coresys: CoreSys = coresys - def _check_cache(self, username, password): + def _check_cache(self, username: str, password: str) -> bool: """Check password in cache.""" - username_h = _rehash(username) - password_h = _rehash(password, username) + username_h = self._rehash(username) + password_h = self._rehash(password, username) if self._data.get(username_h) == password_h: _LOGGER.info("Cache hit for %s", username) @@ -31,10 +32,10 @@ class Auth(JsonConfig, CoreSysAttributes): _LOGGER.warning("No cache hit for %s", username) return False - def _update_cache(self, username, password): + def _update_cache(self, username: str, password: str) -> None: """Cache a username, password.""" - username_h = _rehash(username) - password_h = _rehash(password, username) + username_h = self._rehash(username) + password_h = self._rehash(password, username) if self._data.get(username_h) == password_h: return @@ -42,10 +43,10 @@ class Auth(JsonConfig, CoreSysAttributes): self._data[username_h] = password_h self.save_data() - def _dismatch_cache(self, username, password): + def _dismatch_cache(self, username: str, password: str) -> None: """Remove user from cache.""" - username_h = _rehash(username) - password_h = _rehash(password, username) + username_h = self._rehash(username) + password_h = self._rehash(password, username) if self._data.get(username_h) != password_h: return @@ -53,7 +54,7 @@ class Auth(JsonConfig, CoreSysAttributes): self._data.pop(username_h, None) self.save_data() - async def check_login(self, addon, username, password): + async def check_login(self, addon: Addon, username: str, password: str) -> bool: """Check username login.""" if password is None: _LOGGER.error("None as password is not supported!") @@ -89,9 +90,27 @@ class Auth(JsonConfig, CoreSysAttributes): raise AuthError() + async def change_password(self, username: str, password: str) -> None: + """Change user password login.""" + try: + async with self.sys_homeassistant.make_request( + "post", + "api/hassio_auth/password_reset", + json={ATTR_USERNAME: username, ATTR_PASSWORD: password}, + ) as req: + if req.status == 200: + _LOGGER.info("Success password reset %s", username) + return -def _rehash(value, salt2=""): - """Rehash a value.""" - for idx in range(1, 20): - value = hashlib.sha256(f"{value}{idx}{salt2}".encode()).hexdigest() - return value + _LOGGER.warning("Unknown user %s for password reset", username) + except HomeAssistantAPIError: + _LOGGER.error("Can't request password reset on Home Assistant!") + + raise AuthPasswordResetError() + + @staticmethod + def _rehash(value: str, salt2: str = "") -> str: + """Rehash a value.""" + for idx in range(1, 20): + value = hashlib.sha256(f"{value}{idx}{salt2}".encode()).hexdigest() + return value diff --git a/hassio/exceptions.py b/hassio/exceptions.py index 289433a5f..aa14db239 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -97,6 +97,10 @@ class AuthError(HassioError): """Auth errors.""" +class AuthPasswordResetError(HassioError): + """Auth error if password reset fails.""" + + # Host