From 37842c2d29cfa9de99981ba21f9d2e303b20cf9e Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 22 Aug 2018 12:05:55 -0700 Subject: [PATCH 1/9] Add Time-based Onetime Password Multi-factor Auth Add TOTP setup flow, generate QR code --- homeassistant/auth/__init__.py | 8 +- homeassistant/auth/mfa_modules/totp.py | 209 +++++++++++++++++++++ homeassistant/auth/providers/__init__.py | 4 +- homeassistant/components/auth/__init__.py | 2 +- homeassistant/components/auth/strings.json | 16 ++ homeassistant/components/auth/util.py | 61 ++++++ requirements_all.txt | 7 + requirements_test_all.txt | 4 + script/gen_requirements_all.py | 1 + tests/auth/mfa_modules/test_totp.py | 130 +++++++++++++ 10 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 homeassistant/auth/mfa_modules/totp.py create mode 100644 homeassistant/components/auth/strings.json create mode 100644 homeassistant/components/auth/util.py create mode 100644 tests/auth/mfa_modules/test_totp.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e0b7b377b1fc75..fea227aed50d90 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -249,13 +249,13 @@ async def async_disable_user_mfa(self, user: models.User, await module.async_depose_user(user.id) - async def async_get_enabled_mfa(self, user: models.User) -> List[str]: + async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: """List enabled mfa modules for user.""" - module_ids = [] + modules = OrderedDict() for module_id, module in self._mfa_modules.items(): if await module.async_is_user_setup(user.id): - module_ids.append(module_id) - return module_ids + modules[module_id] = module.name + return modules async def async_create_refresh_token(self, user: models.User, client_id: Optional[str] = None) \ diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py new file mode 100644 index 00000000000000..24bbb46ce02d4c --- /dev/null +++ b/homeassistant/auth/mfa_modules/totp.py @@ -0,0 +1,209 @@ +"""Time-based One Time Password auth module.""" +import logging +from typing import Any, Dict, Optional, Tuple # noqa: F401 + +import voluptuous as vol + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant +from homeassistant.loader import bind_hass + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1', 'pypng==0.0.18'] + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_module.totp' +STORAGE_USERS = 'users' +STORAGE_USER_ID = 'user_id' +STORAGE_OTA_SECRET = 'ota_secret' + +INPUT_FIELD_CODE = 'code' + +DUMMY_SECRET = 'FPPTH34D4E3MI2HG' + +_LOGGER = logging.getLogger(__name__) + + +def _generate_qr_code(data: str) -> str: + """Generate a base64 PNG string represent QR Code image of data.""" + import pyqrcode + + qr_code = pyqrcode.create(data) + return str(qr_code.png_as_base64_str(scale=4)) + + +@bind_hass +async def _async_generate_secret(hass: HomeAssistant, username: str) \ + -> Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + def generate_secret_helper(username: str) -> Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + import pyotp + + ota_secret = pyotp.random_base32() + url = pyotp.totp.TOTP(ota_secret).provisioning_uri( + username, issuer_name="Home Assistant") + image = _generate_qr_code(url) + return ota_secret, url, image + + return await hass.async_add_executor_job( + generate_secret_helper, username) + + +@MULTI_FACTOR_AUTH_MODULES.register('totp') +class TotpAuthModule(MultiFactorAuthModule): + """Auth module validate time-based one time password.""" + + DEFAULT_TITLE = 'Time-based One Time Password' + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._users = None # type: Optional[Dict[str, str]] + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY) + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._users = data.get(STORAGE_USERS, {}) + + async def _async_save(self) -> None: + """Save data.""" + await self._user_store.async_save({STORAGE_USERS: self._users}) + + def _add_ota_secret(self, user_id: str, + secret: Optional[str] = None) -> str: + """Create a ota_secret for user.""" + import pyotp + + ota_secret = secret or pyotp.random_base32() # type: str + + self._users[user_id] = ota_secret # type: ignore + return ota_secret + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + user = await self.hass.auth.async_get_user(user_id) # type: ignore + return TotpSetupFlow(self, user) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> str: + """Set up auth module for user.""" + if self._users is None: + await self._async_load() + + result = await self.hass.async_add_executor_job( + self._add_ota_secret, user_id, setup_data.get('secret')) + + await self._async_save() + return result + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._users is None: + await self._async_load() + + if self._users.pop(user_id, None): # type: ignore + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._users is None: + await self._async_load() + + return user_id in self._users # type: ignore + + async def async_validation( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._users is None: + await self._async_load() + + # user_input has been validate in caller + return await self.hass.async_add_executor_job( + self._validate_2fa, user_id, user_input[INPUT_FIELD_CODE]) + + def _validate_2fa(self, user_id: str, code: str) -> bool: + """Validate two factor authentication code.""" + import pyotp + + ota_secret = self._users.get(user_id) # type: ignore + if ota_secret is None: + # even we cannot find user, we still do verify + # to make timing the same as if user was found. + pyotp.TOTP(DUMMY_SECRET).verify(code) + return False + + return bool(pyotp.TOTP(ota_secret).verify(code)) + + +class TotpSetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__(self, auth_module: TotpAuthModule, + user: User) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, user.id) + self._auth_module = auth_module # type: TotpAuthModule + self._user = user + self._ota_secret = None # type: Optional[str] + self._url = None # type Optional[str] + self._image = None # type Optional[str] + + 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 self.async_create_entry(data={'result': result}) if finish. + """ + import pyotp + + errors = {} # type: Dict[str, str] + + if user_input: + verified = await self.hass.async_add_executor_job( # type: ignore + pyotp.TOTP(self._ota_secret).verify, user_input['code']) + if verified: + result = await self._auth_module.async_setup_user( + self._user_id, {'secret': self._ota_secret}) + return self.async_create_entry( + title=self._auth_module.name, + data={'result': result} + ) + + errors['base'] = 'invalid_code' + + else: + self._ota_secret, self._url, self._image = ( # type: ignore + await _async_generate_secret( + self._auth_module.hass, str(self._user.name))) + + return self.async_show_form( + step_id='init', + data_schema=self._auth_module.input_schema, + description_placeholders={ + 'code': self._ota_secret, + 'url': self._url, + 'qr_code': self._image + }, + errors=errors + ) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index e8ef7cbf3d4f87..0bcb47d4af9e22 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -168,7 +168,7 @@ def __init__(self, auth_provider: AuthProvider) -> None: self._auth_provider = auth_provider self._auth_module_id = None # type: Optional[str] self._auth_manager = auth_provider.hass.auth # type: ignore - self.available_mfa_modules = [] # type: List + self.available_mfa_modules = {} # type: Dict[str, str] self.created_at = dt_util.utcnow() self.user = None # type: Optional[User] @@ -196,7 +196,7 @@ async def async_step_select_mfa_module( errors['base'] = 'invalid_auth_module' if len(self.available_mfa_modules) == 1: - self._auth_module_id = self.available_mfa_modules[0] + self._auth_module_id = list(self.available_mfa_modules.keys())[0] return await self.async_step_mfa() return self.async_show_form( diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index a87e646761c472..e1d35749a016e8 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -76,7 +76,7 @@ from . import mfa_setup_flow DOMAIN = 'auth' -DEPENDENCIES = ['http'] +DEPENDENCIES = ['http', 'websocket_api'] WS_TYPE_CURRENT_USER = 'auth/current_user' SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json new file mode 100644 index 00000000000000..bff78666a52fe9 --- /dev/null +++ b/homeassistant/components/auth/strings.json @@ -0,0 +1,16 @@ +{ + "mfa_setup":{ + "totp": { + "title": "TOTP", + "step": { + "init": { + "title": "Scan this QR code with your app", + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. \nOr enter {code} instead. \n\n[QR Code]{url}", + }, + }, + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/util.py b/homeassistant/components/auth/util.py new file mode 100644 index 00000000000000..d7e0d6e2aad315 --- /dev/null +++ b/homeassistant/components/auth/util.py @@ -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( + only_owner=False, only_system_user=False, allow_system_user=True, + only_active_user=True, only_inactive_user=False): + """Decorate function validating login user exist in current WS connection. + + Will write out error message if not authenticated. + """ + def validator(func): + """Decorate func.""" + @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 diff --git a/requirements_all.txt b/requirements_all.txt index 25480a023ec0fa..c2f405300f828e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -46,6 +46,9 @@ PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.auth.mfa_modules.totp +PyQRCode==1.2.1 + # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.0.7 @@ -985,6 +988,7 @@ pyopenuv==1.0.1 # homeassistant.components.iota pyota==2.0.5 +# homeassistant.auth.mfa_modules.totp # homeassistant.components.sensor.otp pyotp==2.2.6 @@ -995,6 +999,9 @@ pyowm==2.9.0 # homeassistant.components.media_player.pjlink pypjlink2==1.2.0 +# homeassistant.auth.mfa_modules.totp +pypng==0.0.18 + # homeassistant.components.sensor.pollen pypollencom==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71cbc724c59977..5f655afb1b2932 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -154,6 +154,10 @@ pymonoprice==0.3 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.auth.mfa_modules.totp +# homeassistant.components.sensor.otp +pyotp==2.2.6 + # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e26393bb800c58..fe23e638e5b76c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -78,6 +78,7 @@ 'pylitejet', 'pymonoprice', 'pynx584', + 'pyotp', 'pyqwikswitch', 'PyRMVtransport', 'python-forecastio', diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py new file mode 100644 index 00000000000000..28e6c949bc4ad4 --- /dev/null +++ b/tests/auth/mfa_modules/test_totp.py @@ -0,0 +1,130 @@ +"""Test the Time-based One Time Password (MFA) auth module.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.auth import models as auth_models, auth_manager_from_config +from homeassistant.auth.mfa_modules import auth_mfa_module_from_config +from tests.common import MockUser + +MOCK_CODE = '123456' + + +async def test_validating_mfa(hass): + """Test validating mfa code.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + with patch('pyotp.TOTP.verify', return_value=True): + assert await totp_auth_module.async_validation( + 'test-user', {'code': MOCK_CODE}) + + +async def test_validating_mfa_invalid_code(hass): + """Test validating an invalid mfa code.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + with patch('pyotp.TOTP.verify', return_value=False): + assert await totp_auth_module.async_validation( + 'test-user', {'code': MOCK_CODE}) is False + + +async def test_validating_mfa_invalid_user(hass): + """Test validating an mfa code with invalid user.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + assert await totp_auth_module.async_validation( + 'invalid-user', {'code': MOCK_CODE}) is False + + +async def test_setup_depose_user(hass): + """Test despose user.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + result = await totp_auth_module.async_setup_user('test-user', {}) + assert len(totp_auth_module._users) == 1 + result2 = await totp_auth_module.async_setup_user('test-user', {}) + assert len(totp_auth_module._users) == 1 + assert result != result2 + + await totp_auth_module.async_depose_user('test-user') + assert len(totp_auth_module._users) == 0 + + result = await totp_auth_module.async_setup_user( + 'test-user2', {'secret': 'secret-code'}) + assert result == 'secret-code' + assert len(totp_auth_module._users) == 1 + + +async def test_login_flow_validates_mfa(hass): + """Test login flow with mfa enabled.""" + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{'username': 'test-user', 'password': 'test-pass'}], + }], [{ + 'type': 'totp', + }]) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(hass.auth) + await hass.auth.async_link_user(user, auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + await hass.auth.async_enable_user_mfa(user, 'totp', {}) + + provider = hass.auth.auth_providers[0] + + result = await hass.auth.login_flow.async_init( + (provider.type, provider.id)) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['data_schema'].schema.get('code') == str + + with patch('pyotp.TOTP.verify', return_value=False): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': 'invalid-code'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['errors']['base'] == 'invalid_auth' + + with patch('pyotp.TOTP.verify', return_value=True): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': MOCK_CODE}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'].id == 'mock-user' From 1911abbbb9ac623ac802d68ef528f2ea6f0fe7c5 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 24 Aug 2018 10:43:58 -0700 Subject: [PATCH 2/9] Resolve rebase issue --- homeassistant/auth/mfa_modules/totp.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 24bbb46ce02d4c..4e4fb0f5be97d7 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -102,7 +102,7 @@ async def async_setup_flow(self, user_id: str) -> SetupFlow: Mfa module should extend SetupFlow """ user = await self.hass.auth.async_get_user(user_id) # type: ignore - return TotpSetupFlow(self, user) + return TotpSetupFlow(self, self.input_schema, user) async def async_setup_user(self, user_id: str, setup_data: Any) -> str: """Set up auth module for user.""" @@ -158,9 +158,11 @@ class TotpSetupFlow(SetupFlow): """Handler for the setup flow.""" def __init__(self, auth_module: TotpAuthModule, + setup_schema: vol.Schema, user: User) -> None: """Initialize the setup flow.""" - super().__init__(auth_module, user.id) + super().__init__(auth_module, setup_schema, user.id) + # to fix typing complaint self._auth_module = auth_module # type: TotpAuthModule self._user = user self._ota_secret = None # type: Optional[str] @@ -199,7 +201,7 @@ async def async_step_init( return self.async_show_form( step_id='init', - data_schema=self._auth_module.input_schema, + data_schema=self._setup_schema, description_placeholders={ 'code': self._ota_secret, 'url': self._url, From 145aefeaa235928910edd9bfc5ea07e84662b2eb Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 24 Aug 2018 11:08:11 -0700 Subject: [PATCH 3/9] Use svg instead png for QR code --- homeassistant/auth/mfa_modules/totp.py | 36 ++++++++++------------ homeassistant/components/auth/strings.json | 2 +- requirements_all.txt | 3 -- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 4e4fb0f5be97d7..e488b374c77e01 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,17 +1,18 @@ """Time-based One Time Password auth module.""" import logging +from base64 import b64encode +from io import BytesIO from typing import Any, Dict, Optional, Tuple # noqa: F401 import voluptuous as vol from homeassistant.auth.models import User from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow -REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1', 'pypng==0.0.18'] +REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1'] CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) @@ -34,25 +35,22 @@ def _generate_qr_code(data: str) -> str: import pyqrcode qr_code = pyqrcode.create(data) - return str(qr_code.png_as_base64_str(scale=4)) + with BytesIO() as buffer: + qr_code.svg(file=buffer, scale=4) + return 'data:image/svg+xml;base64,{}'.format( + b64encode(buffer.getvalue()).decode("ascii")) -@bind_hass -async def _async_generate_secret(hass: HomeAssistant, username: str) \ - -> Tuple[str, str, str]: - """Generate a secret, url, and QR code.""" - def generate_secret_helper(username: str) -> Tuple[str, str, str]: - """Generate a secret, url, and QR code.""" - import pyotp - ota_secret = pyotp.random_base32() - url = pyotp.totp.TOTP(ota_secret).provisioning_uri( - username, issuer_name="Home Assistant") - image = _generate_qr_code(url) - return ota_secret, url, image +def _generate_secret_and_qr_code(username: str) -> Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + import pyotp - return await hass.async_add_executor_job( - generate_secret_helper, username) + ota_secret = pyotp.random_base32() + url = pyotp.totp.TOTP(ota_secret).provisioning_uri( + username, issuer_name="Home Assistant") + image = _generate_qr_code(url) + return ota_secret, url, image @MULTI_FACTOR_AUTH_MODULES.register('totp') @@ -196,8 +194,8 @@ async def async_step_init( else: self._ota_secret, self._url, self._image = ( # type: ignore - await _async_generate_secret( - self._auth_module.hass, str(self._user.name))) + await self._auth_module.hass.async_add_executor_job( + _generate_secret_and_qr_code, str(self._user.name))) return self.async_show_form( step_id='init', diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index bff78666a52fe9..b9cad8e821f4da 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,7 +5,7 @@ "step": { "init": { "title": "Scan this QR code with your app", - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. \nOr enter {code} instead. \n\n[QR Code]{url}", + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. \nOr enter {code} instead. \n\n![url]{qr_code}", }, }, "error": { diff --git a/requirements_all.txt b/requirements_all.txt index c2f405300f828e..9537bcd623dad1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -999,9 +999,6 @@ pyowm==2.9.0 # homeassistant.components.media_player.pjlink pypjlink2==1.2.0 -# homeassistant.auth.mfa_modules.totp -pypng==0.0.18 - # homeassistant.components.sensor.pollen pypollencom==2.1.0 From a2cfebc140022fe1fd1dfdd83cab532691903b20 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 24 Aug 2018 11:14:37 -0700 Subject: [PATCH 4/9] Lint and typing --- homeassistant/auth/__init__.py | 2 +- homeassistant/auth/mfa_modules/totp.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index fea227aed50d90..952bb3b8352ccf 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -251,7 +251,7 @@ async def async_disable_user_mfa(self, user: models.User, async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: """List enabled mfa modules for user.""" - modules = OrderedDict() + modules = OrderedDict() # type: Dict[str, str] for module_id, module in self._mfa_modules.items(): if await module.async_is_user_setup(user.id): modules[module_id] = module.name diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index e488b374c77e01..552557e5c5fc29 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -193,9 +193,10 @@ async def async_step_init( errors['base'] = 'invalid_code' else: - self._ota_secret, self._url, self._image = ( # type: ignore - await self._auth_module.hass.async_add_executor_job( - _generate_secret_and_qr_code, str(self._user.name))) + hass = self._auth_module.hass + self._ota_secret, self._url, self._image = \ + await hass.async_add_executor_job( # type: ignore + _generate_secret_and_qr_code, str(self._user.name)) return self.async_show_form( step_id='init', From 06e015f922eeec572cbb8750e8e036e85c925598 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 25 Aug 2018 10:56:21 -0700 Subject: [PATCH 5/9] Fix translation --- .../components/auth/.translations/en.json | 16 ++++++++++++++++ homeassistant/components/auth/strings.json | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/auth/.translations/en.json diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json new file mode 100644 index 00000000000000..c6ecf9a5529ecd --- /dev/null +++ b/homeassistant/components/auth/.translations/en.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." + }, + "step": { + "init": { + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n![{url}]({qr_code})\n\nEnter the six digi code appeared in your app below to verify the setup:", + "title": "Scan this QR code with your app" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index b9cad8e821f4da..a95104d23a42bd 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,8 +5,8 @@ "step": { "init": { "title": "Scan this QR code with your app", - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. \nOr enter {code} instead. \n\n![url]{qr_code}", - }, + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n![{url}]({qr_code})\n\nEnter the six digi code appeared in your app below to verify the setup:" + } }, "error": { "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." From b98f65de9f601d8a902072c2ec4a5796fd222d1c Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 25 Aug 2018 15:52:30 -0700 Subject: [PATCH 6/9] Load totp auth module by default --- homeassistant/config.py | 6 +++++- tests/test_config.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 45505bbbc9b135..0af9ee7a6d718f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -424,10 +424,14 @@ async def async_process_ha_core_config( if has_api_password: auth_conf.append({'type': 'legacy_api_password'}) + mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [ + {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'} + ]) + setattr(hass, 'auth', await auth.auth_manager_from_config( hass, auth_conf, - config.get(CONF_AUTH_MFA_MODULES, []))) + mfa_conf)) hac = hass.config diff --git a/tests/test_config.py b/tests/test_config.py index 77a30fd771b358..152dddbcbc29ae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,7 +16,7 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, - CONF_AUTH_PROVIDERS) + CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async_ import run_coroutine_threadsafe @@ -805,6 +805,10 @@ async def test_auth_provider_config(hass): CONF_AUTH_PROVIDERS: [ {'type': 'homeassistant'}, {'type': 'legacy_api_password'}, + ], + CONF_AUTH_MFA_MODULES: [ + {'type': 'totp'}, + {'type': 'totp', 'id': 'second'}, ] } if hasattr(hass, 'auth'): @@ -815,6 +819,9 @@ async def test_auth_provider_config(hass): assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' assert hass.auth.active is True + assert len(hass.auth.auth_mfa_modules) == 2 + assert hass.auth.auth_mfa_modules[0].id == 'totp' + assert hass.auth.auth_mfa_modules[1].id == 'second' async def test_auth_provider_config_default(hass): @@ -834,6 +841,8 @@ async def test_auth_provider_config_default(hass): assert len(hass.auth.auth_providers) == 1 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.active is True + assert len(hass.auth.auth_mfa_modules) == 1 + assert hass.auth.auth_mfa_modules[0].id == 'totp' async def test_auth_provider_config_default_api_password(hass): From 535abf05fa639a394997b6af9826e5ba46fb93d8 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 26 Aug 2018 07:43:27 -0700 Subject: [PATCH 7/9] use tag instead markdown image --- homeassistant/auth/mfa_modules/totp.py | 8 +++++--- homeassistant/components/auth/.translations/en.json | 2 +- homeassistant/components/auth/strings.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 552557e5c5fc29..48531863c1a35d 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,6 +1,5 @@ """Time-based One Time Password auth module.""" import logging -from base64 import b64encode from io import BytesIO from typing import Any, Dict, Optional, Tuple # noqa: F401 @@ -38,8 +37,11 @@ def _generate_qr_code(data: str) -> str: with BytesIO() as buffer: qr_code.svg(file=buffer, scale=4) - return 'data:image/svg+xml;base64,{}'.format( - b64encode(buffer.getvalue()).decode("ascii")) + return '{}'.format( + buffer.getvalue().decode("ascii").replace('\n', '') + .replace('' + ' Tuple[str, str, str]: diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json index c6ecf9a5529ecd..5c1af67b120ce3 100644 --- a/homeassistant/components/auth/.translations/en.json +++ b/homeassistant/components/auth/.translations/en.json @@ -6,7 +6,7 @@ }, "step": { "init": { - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n![{url}]({qr_code})\n\nEnter the six digi code appeared in your app below to verify the setup:", + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n{qr_code}\n\nEnter the six digi code appeared in your app below to verify the setup:", "title": "Scan this QR code with your app" } }, diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index a95104d23a42bd..8362de15efdef5 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,7 +5,7 @@ "step": { "init": { "title": "Scan this QR code with your app", - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n![{url}]({qr_code})\n\nEnter the six digi code appeared in your app below to verify the setup:" + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n{qr_code}\n\nEnter the six digi code appeared in your app below to verify the setup:" } }, "error": { From a8b4ef58922dbad735551c37726961e3182ace1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 21:58:11 +0200 Subject: [PATCH 8/9] Update strings --- homeassistant/components/auth/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index 8362de15efdef5..b0083ab577b4c0 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -4,13 +4,13 @@ "title": "TOTP", "step": { "init": { - "title": "Scan this QR code with your app", - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n{qr_code}\n\nEnter the six digi code appeared in your app below to verify the setup:" + "title": "Set up two-factor authentication using TOTP", + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." } }, "error": { - "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." } } } -} \ No newline at end of file +} From dabafbe3429cba2cb788e868b0affe1342fc3327 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 22:14:46 +0200 Subject: [PATCH 9/9] Cleanup --- homeassistant/components/auth/__init__.py | 2 +- homeassistant/components/auth/util.py | 61 ----------------------- 2 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 homeassistant/components/auth/util.py diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index e1d35749a016e8..a87e646761c472 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -76,7 +76,7 @@ from . import mfa_setup_flow DOMAIN = 'auth' -DEPENDENCIES = ['http', 'websocket_api'] +DEPENDENCIES = ['http'] WS_TYPE_CURRENT_USER = 'auth/current_user' SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ diff --git a/homeassistant/components/auth/util.py b/homeassistant/components/auth/util.py deleted file mode 100644 index d7e0d6e2aad315..00000000000000 --- a/homeassistant/components/auth/util.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Auth component utils.""" -from functools import wraps - -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant - - -def validate_current_user( - only_owner=False, only_system_user=False, allow_system_user=True, - only_active_user=True, only_inactive_user=False): - """Decorate function validating login user exist in current WS connection. - - Will write out error message if not authenticated. - """ - def validator(func): - """Decorate func.""" - @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