Skip to content

Add Notify MFA module#16314

Merged
balloob merged 13 commits intohome-assistant:devfrom
awarecan:mfa-notify
Sep 24, 2018
Merged

Add Notify MFA module#16314
balloob merged 13 commits intohome-assistant:devfrom
awarecan:mfa-notify

Conversation

@awarecan
Copy link
Copy Markdown
Contributor

@awarecan awarecan commented Aug 31, 2018

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):

homeassistant:
  auth_mfa_modules:
     - type: notify

Checklist:

  • The code change is tested and works locally.
  • Local tests pass with tox. Your PR cannot be merged unless tests pass

If user exposed functionality or configuration variables are added/changed:

If the code does not interact with devices:

  • Tests have been added to verify that the new code works.

@awarecan
Copy link
Copy Markdown
Contributor Author

Almost done. Need add translation, tweak some wording, and do more tests on real notify service

Comment thread tests/auth/mfa_modules/test_notify.py Outdated

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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local variable 'notify_calls' is assigned to but never used

@awarecan awarecan changed the title WIP: Add Notify MFA module Add Notify MFA module Sep 1, 2018
"""Return True if validation passed."""
raise NotImplementedError

async def async_generate(self, user_id: str) -> Optional[str]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo


def _validate_one_time_password(self, user_id: str, code: str) -> bool:
"""Validate one time password."""
ota_secret, counter, notify_service, target = \
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this is necessary, as a user has to pick a MFA module to use.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case the HTTP request got pollution

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we need add either retry limit or time delay between failed attempts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MFA login flows expire after 5 minutes, wouldn't that be enough?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. What about 3 tries? After that we send a new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For notify limit to 3 times. After limit flow will abort
For totp limit to 5 times

Comment thread homeassistant/config.py Outdated
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'}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too many blank lines (2)

@awarecan
Copy link
Copy Markdown
Contributor Author

awarecan commented Sep 5, 2018

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'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just always use a random input for counter value?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use 8 digit number for the code, looks better than 6 digit

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used.

def _generate_otp(secret: str, count: int) -> str:
"""Generate one time password."""
import pyotp
return str(pyotp.HOTP(secret, digits=8).at(count))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not persistent, we shouldn't put it on the storage class.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used.

@callback
def aync_get_available_notify_services(self) -> List[str]:
"""Return list of notify services."""
unordered_services = list(self.hass.services.async_services().get(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This too could be moved to a MFA flow if we had it, allowing each MFA implementation to decide it on their own.

@awarecan
Copy link
Copy Markdown
Contributor Author

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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's shorter code to just write the dict out

Copy link
Copy Markdown
Member

@balloob balloob left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok let's merge and do the refactor in another PR.

@balloob balloob merged commit 7a77951 into home-assistant:dev Sep 24, 2018
@ghost ghost removed the in progress label Sep 24, 2018
@balloob balloob mentioned this pull request Sep 28, 2018
@home-assistant home-assistant locked and limited conversation to collaborators Feb 5, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants