2019-02-07 00:36:41 +00:00
|
|
|
"""Auth provider that validates credentials via an external command."""
|
|
|
|
|
|
|
|
import asyncio.subprocess
|
|
|
|
import collections
|
|
|
|
import logging
|
|
|
|
import os
|
2019-12-09 15:42:10 +00:00
|
|
|
from typing import Any, Dict, Optional, cast
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
2021-02-04 00:19:22 +00:00
|
|
|
from homeassistant.const import CONF_COMMAND
|
2019-02-07 00:36:41 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
|
2019-12-09 15:42:10 +00:00
|
|
|
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
2019-02-07 00:36:41 +00:00
|
|
|
from ..models import Credentials, UserMeta
|
|
|
|
|
|
|
|
CONF_ARGS = "args"
|
|
|
|
CONF_META = "meta"
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
|
|
|
|
{
|
|
|
|
vol.Required(CONF_COMMAND): vol.All(
|
|
|
|
str, os.path.normpath, msg="must be an absolute path"
|
|
|
|
),
|
|
|
|
vol.Optional(CONF_ARGS, default=None): vol.Any(vol.DefaultTo(list), [str]),
|
|
|
|
vol.Optional(CONF_META, default=False): bool,
|
|
|
|
},
|
|
|
|
extra=vol.PREVENT_EXTRA,
|
|
|
|
)
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class InvalidAuthError(HomeAssistantError):
|
|
|
|
"""Raised when authentication with given credentials fails."""
|
|
|
|
|
|
|
|
|
|
|
|
@AUTH_PROVIDERS.register("command_line")
|
|
|
|
class CommandLineAuthProvider(AuthProvider):
|
|
|
|
"""Auth provider validating credentials by calling a command."""
|
|
|
|
|
|
|
|
DEFAULT_TITLE = "Command Line Authentication"
|
|
|
|
|
|
|
|
# which keys to accept from a program's stdout
|
|
|
|
ALLOWED_META_KEYS = ("name",)
|
|
|
|
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
"""Extend parent's __init__.
|
|
|
|
|
|
|
|
Adds self._user_meta dictionary to hold the user-specific
|
|
|
|
attributes provided by external programs.
|
|
|
|
"""
|
|
|
|
super().__init__(*args, **kwargs)
|
2019-09-04 03:36:04 +00:00
|
|
|
self._user_meta: Dict[str, Dict[str, Any]] = {}
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
async def async_login_flow(self, context: Optional[dict]) -> LoginFlow:
|
|
|
|
"""Return a flow to login."""
|
|
|
|
return CommandLineLoginFlow(self)
|
|
|
|
|
|
|
|
async def async_validate_login(self, username: str, password: str) -> None:
|
|
|
|
"""Validate a username and password."""
|
2019-07-31 19:25:30 +00:00
|
|
|
env = {"username": username, "password": password}
|
2019-02-07 00:36:41 +00:00
|
|
|
try:
|
2020-05-09 11:08:40 +00:00
|
|
|
process = await asyncio.subprocess.create_subprocess_exec( # pylint: disable=no-member
|
2019-07-31 19:25:30 +00:00
|
|
|
self.config[CONF_COMMAND],
|
|
|
|
*self.config[CONF_ARGS],
|
2019-02-07 00:36:41 +00:00
|
|
|
env=env,
|
2019-07-31 19:25:30 +00:00
|
|
|
stdout=asyncio.subprocess.PIPE if self.config[CONF_META] else None,
|
2019-02-07 00:36:41 +00:00
|
|
|
)
|
2019-07-31 19:25:30 +00:00
|
|
|
stdout, _ = await process.communicate()
|
2019-02-07 00:36:41 +00:00
|
|
|
except OSError as err:
|
|
|
|
# happens when command doesn't exist or permission is denied
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.error("Error while authenticating %r: %s", username, err)
|
2020-08-28 11:50:32 +00:00
|
|
|
raise InvalidAuthError from err
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
if process.returncode != 0:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.error(
|
2020-07-05 21:04:19 +00:00
|
|
|
"User %r failed to authenticate, command exited with code %d",
|
2019-07-31 19:25:30 +00:00
|
|
|
username,
|
|
|
|
process.returncode,
|
|
|
|
)
|
2019-02-07 00:36:41 +00:00
|
|
|
raise InvalidAuthError
|
|
|
|
|
|
|
|
if self.config[CONF_META]:
|
2019-09-04 03:36:04 +00:00
|
|
|
meta: Dict[str, str] = {}
|
2019-02-07 00:36:41 +00:00
|
|
|
for _line in stdout.splitlines():
|
|
|
|
try:
|
|
|
|
line = _line.decode().lstrip()
|
|
|
|
if line.startswith("#"):
|
|
|
|
continue
|
|
|
|
key, value = line.split("=", 1)
|
|
|
|
except ValueError:
|
|
|
|
# malformed line
|
|
|
|
continue
|
|
|
|
key = key.strip()
|
|
|
|
value = value.strip()
|
|
|
|
if key in self.ALLOWED_META_KEYS:
|
|
|
|
meta[key] = value
|
|
|
|
self._user_meta[username] = meta
|
|
|
|
|
|
|
|
async def async_get_or_create_credentials(
|
2019-07-31 19:25:30 +00:00
|
|
|
self, flow_result: Dict[str, str]
|
2019-02-07 00:36:41 +00:00
|
|
|
) -> Credentials:
|
|
|
|
"""Get credentials based on the flow result."""
|
|
|
|
username = flow_result["username"]
|
|
|
|
for credential in await self.async_credentials():
|
|
|
|
if credential.data["username"] == username:
|
|
|
|
return credential
|
|
|
|
|
|
|
|
# Create new credentials.
|
2019-07-31 19:25:30 +00:00
|
|
|
return self.async_create_credentials({"username": username})
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
async def async_user_meta_for_credentials(
|
2019-07-31 19:25:30 +00:00
|
|
|
self, credentials: Credentials
|
2019-02-07 00:36:41 +00:00
|
|
|
) -> UserMeta:
|
|
|
|
"""Return extra user metadata for credentials.
|
|
|
|
|
|
|
|
Currently, only name is supported.
|
|
|
|
"""
|
|
|
|
meta = self._user_meta.get(credentials.data["username"], {})
|
2019-07-31 19:25:30 +00:00
|
|
|
return UserMeta(name=meta.get("name"), is_active=True)
|
2019-02-07 00:36:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
class CommandLineLoginFlow(LoginFlow):
|
|
|
|
"""Handler for the login flow."""
|
|
|
|
|
|
|
|
async def async_step_init(
|
2019-07-31 19:25:30 +00:00
|
|
|
self, user_input: Optional[Dict[str, str]] = None
|
2019-02-07 00:36:41 +00:00
|
|
|
) -> Dict[str, Any]:
|
|
|
|
"""Handle the step of the form."""
|
|
|
|
errors = {}
|
|
|
|
|
|
|
|
if user_input is not None:
|
|
|
|
user_input["username"] = user_input["username"].strip()
|
|
|
|
try:
|
2019-07-31 19:25:30 +00:00
|
|
|
await cast(
|
|
|
|
CommandLineAuthProvider, self._auth_provider
|
|
|
|
).async_validate_login(user_input["username"], user_input["password"])
|
2019-02-07 00:36:41 +00:00
|
|
|
except InvalidAuthError:
|
|
|
|
errors["base"] = "invalid_auth"
|
|
|
|
|
|
|
|
if not errors:
|
|
|
|
user_input.pop("password")
|
|
|
|
return await self.async_finish(user_input)
|
|
|
|
|
2019-09-04 03:36:04 +00:00
|
|
|
schema: Dict[str, type] = collections.OrderedDict()
|
2019-02-07 00:36:41 +00:00
|
|
|
schema["username"] = str
|
|
|
|
schema["password"] = str
|
|
|
|
|
|
|
|
return self.async_show_form(
|
2019-07-31 19:25:30 +00:00
|
|
|
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
2019-02-07 00:36:41 +00:00
|
|
|
)
|