Add Time-based Onetime Password Multi-factor Authentication Module (#16129)
* Add Time-based Onetime Password Multi-factor Auth Add TOTP setup flow, generate QR code * Resolve rebase issue * Use svg instead png for QR code * Lint and typing * Fix translation * Load totp auth module by default * use <svg> tag instead markdown image * Update strings * Cleanup
This commit is contained in:
parent
69d104bcb6
commit
47755fb1e9
11 changed files with 404 additions and 8 deletions
|
@ -249,13 +249,13 @@ class AuthManager:
|
|||
|
||||
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() # type: Dict[str, str]
|
||||
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) \
|
||||
|
|
212
homeassistant/auth/mfa_modules/totp.py
Normal file
212
homeassistant/auth/mfa_modules/totp.py
Normal file
|
@ -0,0 +1,212 @@
|
|||
"""Time-based One Time Password auth module."""
|
||||
import logging
|
||||
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 . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
|
||||
|
||||
REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1']
|
||||
|
||||
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)
|
||||
|
||||
with BytesIO() as buffer:
|
||||
qr_code.svg(file=buffer, scale=4)
|
||||
return '{}'.format(
|
||||
buffer.getvalue().decode("ascii").replace('\n', '')
|
||||
.replace('<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"', '<svg')
|
||||
)
|
||||
|
||||
|
||||
def _generate_secret_and_qr_code(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
|
||||
|
||||
|
||||
@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, self.input_schema, 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,
|
||||
setup_schema: vol.Schema,
|
||||
user: User) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
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]
|
||||
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:
|
||||
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',
|
||||
data_schema=self._setup_schema,
|
||||
description_placeholders={
|
||||
'code': self._ota_secret,
|
||||
'url': self._url,
|
||||
'qr_code': self._image
|
||||
},
|
||||
errors=errors
|
||||
)
|
|
@ -168,7 +168,7 @@ class LoginFlow(data_entry_flow.FlowHandler):
|
|||
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 @@ class LoginFlow(data_entry_flow.FlowHandler):
|
|||
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(
|
||||
|
|
16
homeassistant/components/auth/.translations/en.json
Normal file
16
homeassistant/components/auth/.translations/en.json
Normal file
|
@ -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{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"
|
||||
}
|
||||
}
|
||||
}
|
16
homeassistant/components/auth/strings.json
Normal file
16
homeassistant/components/auth/strings.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"mfa_setup":{
|
||||
"totp": {
|
||||
"title": "TOTP",
|
||||
"step": {
|
||||
"init": {
|
||||
"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 of your Home Assistant system is accurate."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -427,10 +427,14 @@ async def async_process_ha_core_config(
|
|||
if has_trusted_networks:
|
||||
auth_conf.append({'type': 'trusted_networks'})
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -78,6 +78,7 @@ TEST_REQUIREMENTS = (
|
|||
'pylitejet',
|
||||
'pymonoprice',
|
||||
'pynx584',
|
||||
'pyotp',
|
||||
'pyqwikswitch',
|
||||
'PyRMVtransport',
|
||||
'python-forecastio',
|
||||
|
|
130
tests/auth/mfa_modules/test_totp.py
Normal file
130
tests/auth/mfa_modules/test_totp.py
Normal file
|
@ -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'
|
|
@ -16,7 +16,7 @@ from homeassistant.const import (
|
|||
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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue