Conversation
|
Almost done. Need add translation, tweak some wording, and do more tests on real notify service |
|
|
||
| async def test_setup_user_no_notify_service(hass): | ||
| """Test setup flow abort if there is no avilable notify service.""" | ||
| notify_calls = async_mock_service( |
There was a problem hiding this comment.
local variable 'notify_calls' is assigned to but never used
| """Return True if validation passed.""" | ||
| raise NotImplementedError | ||
|
|
||
| async def async_generate(self, user_id: str) -> Optional[str]: |
There was a problem hiding this comment.
This should not be part of the MFA module interface. Modules need to generate their own codes and ask for that. There is no need to share this. We can make a reusable function available to generate 6 digit codes.
| notify_service, target) | ||
|
|
||
| @callback | ||
| def aync_get_aviliable_notify_services(self) -> List[str]: |
|
|
||
| def _validate_one_time_password(self, user_id: str, code: str) -> bool: | ||
| """Validate one time password.""" | ||
| ota_secret, counter, notify_service, target = \ |
There was a problem hiding this comment.
We should not use tuples to store data. Use dicts instead.
| ota_secret, counter, notify_service, target = \ | ||
| self._users.get(user_id, (None, 0, None, None)) # type: ignore | ||
| if ota_secret is None: | ||
| # even we cannot find user, we still do verify |
There was a problem hiding this comment.
I don't think that this is necessary, as a user has to pick a MFA module to use.
There was a problem hiding this comment.
In case the HTTP request got pollution
There was a problem hiding this comment.
It doesn't matter, we just can return right away without doing any computation. We only need to care about this if we are verifying a username/password combo and want to have a similar runtime between username not found and incorrect password.
|
|
||
| result = _verify_otp(ota_secret, code, counter) | ||
|
|
||
| # move counter no matter if passed validation |
There was a problem hiding this comment.
Does this mean that if a user makes a typo, the sent code is no longer valid? I think that it's not necessary to use pyotp for this. We just need a 6 digit code that is stored for the duration of the config flow.
There was a problem hiding this comment.
Then we need add either retry limit or time delay between failed attempts.
There was a problem hiding this comment.
MFA login flows expire after 5 minutes, wouldn't that be enough?
There was a problem hiding this comment.
5 min is not enough. I tried direct call pyotp on RPi 3B+, it take 3 mintues 50 seconds to brute force all combination of 6 digital for TOTP and only 2 mintues 40 seconds for HOTP.
There was a problem hiding this comment.
Hmm. What about 3 tries? After that we send a new one.
There was a problem hiding this comment.
For notify limit to 3 times. After limit flow will abort
For totp limit to 5 times
| mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [ | ||
| {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'} | ||
| {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'}, | ||
| {'type': 'notify', 'id': 'notify'} |
There was a problem hiding this comment.
I don't think that we should make it available as a default MFA Module from the get go. SMS notifications are known to be able to be hijacked.
| await self.async_notify_user(user_id, code) | ||
|
|
||
|
|
||
| def _generate_and_send_one_time_password(self, user_id: str) -> str: |
|
Change to increase counter before generate code, allow user try 3 times before login flow abort |
| CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ | ||
| vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), | ||
| vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), | ||
| vol.Optional(CONF_MESSAGE, default='Your Home Assistant One-time Password' |
There was a problem hiding this comment.
Let's change default message around, as notifications usually just show first couple of words.
{} is your Home Assistant login code.
| import pyotp | ||
|
|
||
| ota_secret = pyotp.random_base32() | ||
| counter = SystemRandom().randint(0, 2 << 31) |
There was a problem hiding this comment.
Can't we just always use a random input for counter value?
There was a problem hiding this comment.
Also, do we really need to use pyotp to generate a random code between 100000 and 999999 ? What does it do that makes it more secure?
There was a problem hiding this comment.
For the random counter, strength is same, pyotp internal use SystemRandom as well. However I will change it to use pyotp just looks consistent.
pyotp implemented RFC 4226 for the one-time password generation, I thought it will be secure that we write a routine by ourselves.
There was a problem hiding this comment.
We can use 8 digit number for the code, looks better than 6 digit
There was a problem hiding this comment.
I prefer 6 digits because it's easier to remember when you are typing it from your phone.
| STORAGE_KEY = 'auth_module.notify' | ||
| STORAGE_USERS = 'users' | ||
| STORAGE_USER_ID = 'user_id' | ||
| STORAGE_OTA_SECRET = 'ota_secret' |
| def _generate_otp(secret: str, count: int) -> str: | ||
| """Generate one time password.""" | ||
| import pyotp | ||
| return str(pyotp.HOTP(secret, digits=8).at(count)) |
There was a problem hiding this comment.
I really think we should stick with 6 digits. We are already limiting it at 3 tries and 6 is a) what every other service uses and b) is easier to remember.
| class NotifySetting: | ||
| """Store notify setting for one user.""" | ||
|
|
||
| secret = attr.ib(type=str, factory=_generate_secret) # not persistent |
There was a problem hiding this comment.
If it's not persistent, we shouldn't put it on the storage class.
There was a problem hiding this comment.
Because instantiating it will have the random side effect of generating the secret / random. Meaning during load, all these things get generated without maybe not being needed.
| """Auth module send hmac-based one time password by notify service.""" | ||
|
|
||
| DEFAULT_TITLE = 'Notify One-Time Password' | ||
| DUMMY_SECRET = '7Z5EFWI4RFLVV67G' |
| @callback | ||
| def aync_get_available_notify_services(self) -> List[str]: | ||
| """Return list of notify services.""" | ||
| unordered_services = list(self.hass.services.async_services().get( |
There was a problem hiding this comment.
unordered_services = []
for service in self.hass.services.async_services().get('notify', {}):
if service not in self._exclude:
unordered_services.append(service)| if exclude_service in unordered_services: | ||
| unordered_services.remove(exclude_service) | ||
|
|
||
| if self._include: |
There was a problem hiding this comment.
If this was a set and unorded_services too, you could just do unordered_services &= self._include
| return False | ||
|
|
||
| # user_input has been validate in caller | ||
| return await self.hass.async_add_executor_job( |
There was a problem hiding this comment.
I wonder if we did the right thing with MFA architecture. Would it be possible to get an instance of a flow which can store instance variables instead of storing temp values in our storage object. The latter feels hacky.
There was a problem hiding this comment.
I even wrote a version of that somewhere in the history. The reason I give it up because consider following scenario:
User login, send a notify, for some reason he didn't received the code, so he opens another browser, login, send the second notify, now two code arrived together, can he still use the first code to login in first login session?
I had seen this on other systems, most of them won't allow you to use the first code anyway, for that case, the code is associated with user, not login flow instance.
There was a problem hiding this comment.
I think it's fine to have a code be bound to a login flow. That's what I would have expected.
| self._auth_manager = auth_provider.hass.auth # type: ignore | ||
| self.available_mfa_modules = {} # type: Dict[str, str] | ||
| self.created_at = dt_util.utcnow() | ||
| self.invalid_mfa_times = 0 |
There was a problem hiding this comment.
This too could be moved to a MFA flow if we had it, allowing each MFA implementation to decide it on their own.
34a1455 to
fd20a9b
Compare
|
I resolved several easy-fix code review comment. @balloob are you okay we merge in current state, and leave the mfa re-structure in a separate PR, e.g. create a separated MFA flow |
| return | ||
|
|
||
| await self._user_store.async_save({STORAGE_USERS: { | ||
| user_id: attr.asdict( |
There was a problem hiding this comment.
It's shorter code to just write the dict out
balloob
left a comment
There was a problem hiding this comment.
Ok let's merge and do the refactor in another PR.
Description:
Add a multi-factor authentication module that using notify service to delivery one-time password to user during the login process
Related issue (if applicable): fixes #
Pull request in home-assistant.github.io with documentation (if applicable): home-assistant/home-assistant.io#6131
Dev document: home-assistant/developers.home-assistant#80
Example entry for
configuration.yaml(if applicable):Checklist:
tox. Your PR cannot be merged unless tests passIf user exposed functionality or configuration variables are added/changed:
If the code does not interact with devices: