-
-
Notifications
You must be signed in to change notification settings - Fork 37.8k
Add multi-factor auth module setup flow #16141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
92e6abc
7d87659
1a68117
8f7c5b2
d4e01f9
af963a3
409a76d
58e6b4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |
| import voluptuous as vol | ||
| from voluptuous.humanize import humanize_error | ||
|
|
||
| from homeassistant import requirements | ||
| from homeassistant import requirements, data_entry_flow | ||
| from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.util.decorator import Registry | ||
|
|
@@ -64,15 +64,14 @@ def input_schema(self) -> vol.Schema: | |
| """Return a voluptuous schema to define mfa auth module's input.""" | ||
| raise NotImplementedError | ||
|
|
||
| @property | ||
| def setup_schema(self) -> Optional[vol.Schema]: | ||
| """Return a vol schema to validate mfa auth module's setup input. | ||
| async def async_setup_flow(self, user_id: str) -> 'SetupFlow': | ||
| """Return a data entry flow handler for setup module. | ||
|
|
||
| Optional | ||
| Mfa module should extend SetupFlow | ||
| """ | ||
| return None | ||
| raise NotImplementedError | ||
|
|
||
| async def async_setup_user(self, user_id: str, setup_data: Any) -> None: | ||
| async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: | ||
| """Set up user for mfa auth module.""" | ||
| raise NotImplementedError | ||
|
|
||
|
|
@@ -90,6 +89,42 @@ async def async_validation( | |
| raise NotImplementedError | ||
|
|
||
|
|
||
| class SetupFlow(data_entry_flow.FlowHandler): | ||
| """Handler for the setup flow.""" | ||
|
|
||
| def __init__(self, auth_module: MultiFactorAuthModule, | ||
| setup_schema: vol.Schema, | ||
| user_id: str) -> None: | ||
| """Initialize the setup flow.""" | ||
| self._auth_module = auth_module | ||
| self._setup_schema = setup_schema | ||
| self._user_id = user_id | ||
|
|
||
| async def async_step_init( | ||
| self, user_input: Optional[Dict[str, str]] = None) \ | ||
| -> Dict[str, Any]: | ||
| """Handle the first step of setup flow. | ||
|
|
||
| Return self.async_show_form(step_id='init') if user_input == None. | ||
| Return await self.async_finish(flow_result) if finish. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale comment? |
||
| """ | ||
| errors = {} # type: Dict[str, str] | ||
|
|
||
| if user_input: | ||
| result = await self._auth_module.async_setup_user( | ||
| self._user_id, user_input) | ||
| return self.async_create_entry( | ||
| title=self._auth_module.name, | ||
| data={'result': result} | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id='init', | ||
| data_schema=self._setup_schema, | ||
| errors=errors | ||
| ) | ||
|
|
||
|
|
||
| async def auth_mfa_module_from_config( | ||
| hass: HomeAssistant, config: Dict[str, Any]) \ | ||
| -> Optional[MultiFactorAuthModule]: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,13 +68,16 @@ | |
| from homeassistant.components.http.ban import log_invalid_auth | ||
| from homeassistant.components.http.data_validator import RequestDataValidator | ||
| from homeassistant.components.http.view import HomeAssistantView | ||
| from homeassistant.core import callback | ||
| from homeassistant.core import callback, HomeAssistant | ||
| from homeassistant.util import dt as dt_util | ||
|
|
||
| from . import indieauth | ||
| from . import login_flow | ||
| from . import mfa_setup_flow | ||
| from . import util | ||
|
|
||
| DOMAIN = 'auth' | ||
| DEPENDENCIES = ['http'] | ||
| DEPENDENCIES = ['http', 'websocket_api'] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Websocket API commands can be registered without loading the websocket API. It is not a dependency. If the user wants the websocket API, the commands will be available. |
||
|
|
||
| WS_TYPE_CURRENT_USER = 'auth/current_user' | ||
| SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ | ||
|
|
@@ -100,6 +103,7 @@ async def async_setup(hass, config): | |
| ) | ||
|
|
||
| await login_flow.async_setup(hass, store_result) | ||
| await mfa_setup_flow.async_setup(hass) | ||
|
|
||
| return True | ||
|
|
||
|
|
@@ -315,21 +319,28 @@ def retrieve_result(client_id, result_type, code): | |
| return store_result, retrieve_result | ||
|
|
||
|
|
||
| @util.validate_current_user() | ||
| @callback | ||
| def websocket_current_user(hass, connection, msg): | ||
| def websocket_current_user( | ||
| hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): | ||
| """Return the current user.""" | ||
| user = connection.request.get('hass_user') | ||
|
|
||
| if user is None: | ||
| connection.to_write.put_nowait(websocket_api.error_message( | ||
| msg['id'], 'no_user', 'Not authenticated as a user')) | ||
| return | ||
|
|
||
| connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { | ||
| 'id': user.id, | ||
| 'name': user.name, | ||
| 'is_owner': user.is_owner, | ||
| 'credentials': [{'auth_provider_type': c.auth_provider_type, | ||
| 'auth_provider_id': c.auth_provider_id} | ||
| for c in user.credentials] | ||
| })) | ||
| async def async_get_current_user(user): | ||
| """Get current user.""" | ||
| enabled_modules = await hass.auth.async_get_enabled_mfa(user) | ||
|
|
||
| connection.send_message_outside( | ||
| websocket_api.result_message(msg['id'], { | ||
| 'id': user.id, | ||
| 'name': user.name, | ||
| 'is_owner': user.is_owner, | ||
| 'credentials': [{'auth_provider_type': c.auth_provider_type, | ||
| 'auth_provider_id': c.auth_provider_id} | ||
| for c in user.credentials], | ||
| 'mfa_modules': [{ | ||
| 'id': module.id, | ||
| 'name': module.name, | ||
| 'enabled': module.id in enabled_modules, | ||
| } for module in hass.auth.auth_mfa_modules], | ||
| })) | ||
|
|
||
| hass.async_create_task(async_get_current_user(connection.user)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| """Helpers to setup multi-factor auth module.""" | ||
| import logging | ||
|
|
||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import data_entry_flow | ||
| from homeassistant.components import websocket_api | ||
| from homeassistant.core import callback, HomeAssistant | ||
|
|
||
| from . import util | ||
|
|
||
| WS_TYPE_SETUP_MFA = 'auth/setup_mfa' | ||
| SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ | ||
| vol.Required('type'): WS_TYPE_SETUP_MFA, | ||
| vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str, | ||
| vol.Exclusive('flow_id', 'module_or_flow_id'): str, | ||
| vol.Optional('user_input'): object, | ||
| }) | ||
|
|
||
| WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa' | ||
| SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ | ||
| vol.Required('type'): WS_TYPE_DEPOSE_MFA, | ||
| vol.Required('mfa_module_id'): str, | ||
| }) | ||
|
|
||
| DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager' | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup(hass): | ||
| """Init mfa setup flow manager.""" | ||
| async def _async_create_setup_flow(handler, context, data): | ||
| """Create a setup flow. hanlder is a mfa module.""" | ||
| mfa_module = hass.auth.get_auth_mfa_module(handler) | ||
| if mfa_module is None: | ||
| raise ValueError('Mfa module {} is not found'.format(handler)) | ||
|
|
||
| user_id = data.pop('user_id') | ||
| return await mfa_module.async_setup_flow(user_id) | ||
|
|
||
| async def _async_finish_setup_flow(flow, flow_result): | ||
| _LOGGER.debug('flow_result: %s', flow_result) | ||
| return flow_result | ||
|
|
||
| hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager( | ||
| hass, _async_create_setup_flow, _async_finish_setup_flow) | ||
|
|
||
| hass.components.websocket_api.async_register_command( | ||
| WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA) | ||
|
|
||
| hass.components.websocket_api.async_register_command( | ||
| WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA) | ||
|
|
||
|
|
||
| @callback | ||
| @util.validate_current_user(allow_system_user=False) | ||
| def websocket_setup_mfa( | ||
| hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): | ||
| """Return a setup flow for mfa auth module.""" | ||
| async def async_setup_flow(msg): | ||
| """Helper to return a setup flow for mfa auth module.""" | ||
| flow_manager = hass.data[DATA_SETUP_FLOW_MGR] | ||
|
|
||
| flow_id = msg.get('flow_id') | ||
| if flow_id is not None: | ||
| result = await flow_manager.async_configure( | ||
| flow_id, msg.get('user_input')) | ||
| connection.send_message_outside( | ||
| websocket_api.result_message( | ||
| msg['id'], _prepare_result_json(result))) | ||
| return | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inside this |
||
| mfa_module_id = msg.get('mfa_module_id') | ||
| mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id) | ||
| if mfa_module is None: | ||
| connection.send_message_outside(websocket_api.error_message( | ||
| msg['id'], 'no_module', | ||
| 'MFA module {} is not found'.format(mfa_module_id))) | ||
| return | ||
|
|
||
| result = await flow_manager.async_init( | ||
| mfa_module_id, data={'user_id': connection.user.id}) | ||
|
|
||
| connection.send_message_outside( | ||
| websocket_api.result_message( | ||
| msg['id'], _prepare_result_json(result))) | ||
|
|
||
| hass.async_create_task(async_setup_flow(msg)) | ||
|
|
||
|
|
||
| @callback | ||
| @util.validate_current_user(allow_system_user=False) | ||
| def websocket_depose_mfa( | ||
| hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): | ||
| """Remove user from mfa module.""" | ||
| async def async_depose(msg): | ||
| """Helper to disable user from mfa auth module.""" | ||
| mfa_module_id = msg['mfa_module_id'] | ||
| try: | ||
| await hass.auth.async_disable_user_mfa( | ||
| connection.user, msg['mfa_module_id']) | ||
| except ValueError as err: | ||
| connection.send_message_outside(websocket_api.error_message( | ||
| msg['id'], 'disable_failed', | ||
| 'Cannot disable MFA Module {}: {}'.format( | ||
| mfa_module_id, err))) | ||
| return | ||
|
|
||
| connection.send_message_outside( | ||
| websocket_api.result_message( | ||
| msg['id'], 'done')) | ||
|
|
||
| hass.async_create_task(async_depose(msg)) | ||
|
|
||
|
|
||
| def _prepare_result_json(result): | ||
| """Convert result to JSON.""" | ||
| if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: | ||
| data = result.copy() | ||
| return data | ||
|
|
||
| if result['type'] != data_entry_flow.RESULT_TYPE_FORM: | ||
| return result | ||
|
|
||
| import voluptuous_serialize | ||
|
|
||
| data = result.copy() | ||
|
|
||
| schema = data['data_schema'] | ||
| if schema is None: | ||
| data['data_schema'] = [] | ||
| else: | ||
| data['data_schema'] = voluptuous_serialize.convert(schema) | ||
|
|
||
| return data | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| """Auth component utils.""" | ||
| from functools import wraps | ||
|
|
||
| from homeassistant.components import websocket_api | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
|
|
||
| def validate_current_user( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should have
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that this method should be part of the websocket component. |
||
| only_owner=False, only_system_user=False, allow_system_user=True, | ||
| only_active_user=True, only_inactive_user=False): | ||
| """Decorator that will validate login user exist in current WS connection. | ||
|
|
||
| Will write out error message if not authenticated. | ||
| """ | ||
| def validator(func): | ||
| """Decorator be called.""" | ||
| @wraps(func) | ||
| def check_current_user(hass: HomeAssistant, | ||
| connection: websocket_api.ActiveConnection, | ||
| msg): | ||
| """Check current user.""" | ||
| def output_error(message_id, message): | ||
| """Output error message.""" | ||
| connection.send_message_outside(websocket_api.error_message( | ||
| msg['id'], message_id, message)) | ||
|
|
||
| if connection.user is None: | ||
| output_error('no_user', 'Not authenticated as a user') | ||
| return | ||
|
|
||
| if only_owner and not connection.user.is_owner: | ||
| output_error('only_owner', 'Only allowed as owner') | ||
| return | ||
|
|
||
| if (only_system_user and | ||
| not connection.user.system_generated): | ||
| output_error('only_system_user', | ||
| 'Only allowed as system user') | ||
| return | ||
|
|
||
| if (not allow_system_user | ||
| and connection.user.system_generated): | ||
| output_error('not_system_user', 'Not allowed as system user') | ||
| return | ||
|
|
||
| if (only_active_user and | ||
| not connection.user.is_active): | ||
| output_error('only_active_user', | ||
| 'Only allowed as active user') | ||
| return | ||
|
|
||
| if only_inactive_user and connection.user.is_active: | ||
| output_error('only_inactive_user', | ||
| 'Not allowed as active user') | ||
| return | ||
|
|
||
| return func(hass, connection, msg) | ||
|
|
||
| return check_current_user | ||
|
|
||
| return validator | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is optional, no?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edit: it is required. MFA module has to be set up to enable.