diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 4ef8440de62a2b..b0cebb5fd6c296 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -2,11 +2,13 @@ import asyncio import logging from collections import OrderedDict +from datetime import timedelta from typing import Any, Dict, List, Optional, Tuple, cast import jwt from homeassistant import data_entry_flow +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util @@ -242,8 +244,12 @@ async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: modules[module_id] = module.name return modules - async def async_create_refresh_token(self, user: models.User, - client_id: Optional[str] = None) \ + async def async_create_refresh_token( + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: Optional[str] = None, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ -> models.RefreshToken: """Create a new refresh token for a user.""" if not user.is_active: @@ -254,10 +260,36 @@ async def async_create_refresh_token(self, user: models.User, 'System generated users cannot have refresh tokens connected ' 'to a client.') - if not user.system_generated and client_id is None: + if token_type is None: + if user.system_generated: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL + + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): + raise ValueError( + 'System generated users can only have system type ' + 'refresh tokens') + + if token_type == models.TOKEN_TYPE_NORMAL and client_id is None: raise ValueError('Client is required to generate a refresh token.') - return await self._store.async_create_refresh_token(user, client_id) + if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and + client_name is None): + raise ValueError('Client_name is required for long-lived access ' + 'token') + + if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: + for token in user.refresh_tokens.values(): + if (token.client_name == client_name and token.token_type == + models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN): + # Each client_name can only have one + # long_lived_access_token type of refresh token + raise ValueError('{} already exists'.format(client_name)) + + return await self._store.async_create_refresh_token( + user, client_id, client_name, client_icon, + token_type, access_token_expiration) async def async_get_refresh_token( self, token_id: str) -> Optional[models.RefreshToken]: @@ -280,10 +312,11 @@ def async_create_access_token(self, refresh_token: models.RefreshToken) -> str: """Create a new access token.""" # pylint: disable=no-self-use + now = dt_util.utcnow() return jwt.encode({ 'iss': refresh_token.id, - 'iat': dt_util.utcnow(), - 'exp': dt_util.utcnow() + refresh_token.access_token_expiration, + 'iat': now, + 'exp': now + refresh_token.access_token_expiration, }, refresh_token.jwt_key, algorithm='HS256').decode() async def async_validate_access_token( diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 0f12d69211c645..d78a1f4225ec16 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional # noqa: F401 import hmac +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util @@ -128,11 +129,27 @@ async def async_remove_credentials( self._async_schedule_save() async def async_create_refresh_token( - self, user: models.User, client_id: Optional[str] = None) \ + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: str = models.TOKEN_TYPE_NORMAL, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ -> models.RefreshToken: """Create a new token for a user.""" - refresh_token = models.RefreshToken(user=user, client_id=client_id) + kwargs = { + 'user': user, + 'client_id': client_id, + 'token_type': token_type, + 'access_token_expiration': access_token_expiration + } # type: Dict[str, Any] + if client_name: + kwargs['client_name'] = client_name + if client_icon: + kwargs['client_icon'] = client_icon + + refresh_token = models.RefreshToken(**kwargs) user.refresh_tokens[refresh_token.id] = refresh_token + self._async_schedule_save() return refresh_token @@ -216,10 +233,20 @@ async def _async_load(self) -> None: 'Ignoring refresh token %(id)s with invalid created_at ' '%(created_at)s for user_id %(user_id)s', rt_dict) continue + token_type = rt_dict.get('token_type') + if token_type is None: + if rt_dict['clinet_id'] is None: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL token = models.RefreshToken( id=rt_dict['id'], user=users[rt_dict['user_id']], client_id=rt_dict['client_id'], + # use dict.get to keep backward compatibility + client_name=rt_dict.get('client_name'), + client_icon=rt_dict.get('client_icon'), + token_type=token_type, created_at=created_at, access_token_expiration=timedelta( seconds=rt_dict['access_token_expiration']), @@ -271,6 +298,9 @@ def _data_to_save(self) -> Dict: 'id': refresh_token.id, 'user_id': user.id, 'client_id': refresh_token.client_id, + 'client_name': refresh_token.client_name, + 'client_icon': refresh_token.client_icon, + 'token_type': refresh_token.token_type, 'created_at': refresh_token.created_at.isoformat(), 'access_token_expiration': refresh_token.access_token_expiration.total_seconds(), diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index a6500510e0d291..c5273d7fa1dd3d 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -7,9 +7,12 @@ from homeassistant.util import dt as dt_util -from .const import ACCESS_TOKEN_EXPIRATION from .util import generate_secret +TOKEN_TYPE_NORMAL = 'normal' +TOKEN_TYPE_SYSTEM = 'system' +TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' + @attr.s(slots=True) class User: @@ -37,11 +40,16 @@ class RefreshToken: """RefreshToken for a user to grant new access tokens.""" user = attr.ib(type=User) - client_id = attr.ib(type=str) # type: Optional[str] + client_id = attr.ib(type=Optional[str]) + access_token_expiration = attr.ib(type=timedelta) + client_name = attr.ib(type=Optional[str], default=None) + client_icon = attr.ib(type=Optional[str], default=None) + token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL, + validator=attr.validators.in_(( + TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN))) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - access_token_expiration = attr.ib(type=timedelta, - default=ACCESS_TOKEN_EXPIRATION) token = attr.ib(type=str, default=attr.Factory(lambda: generate_secret(64))) jwt_key = attr.ib(type=str, diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index a87e646761c472..5839b7ec403e7e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -12,6 +12,7 @@ Exchange the authorization code retrieved from the login flow for tokens. { + "client_id": "https://hassbian.local:8123/", "grant_type": "authorization_code", "code": "411ee2f916e648d691e937ae9344681e" } @@ -32,6 +33,7 @@ Request a new access token using a refresh token. { + "client_id": "https://hassbian.local:8123/", "grant_type": "refresh_token", "refresh_token": "IJKLMNOPQRST" } @@ -55,6 +57,67 @@ "action": "revoke" } +# Websocket API + +## Get current user + +Send websocket command `auth/current_user` will return current user of the +active websocket connection. + +{ + "id": 10, + "type": "auth/current_user", +} + +The result payload likes + +{ + "id": 10, + "type": "result", + "success": true, + "result": { + "id": "USER_ID", + "name": "John Doe", + "is_owner': true, + "credentials": [ + { + "auth_provider_type": "homeassistant", + "auth_provider_id": null + } + ], + "mfa_modules": [ + { + "id": "totp", + "name": "TOTP", + "enabled": true, + } + ] + } +} + +## Create a long-lived access token + +Send websocket command `auth/long_lived_access_token` will create +a long-lived access token for current user. Access token will not be saved in +Home Assistant. User need to record the token in secure place. + +{ + "id": 11, + "type": "auth/long_lived_access_token", + "client_name": "GPS Logger", + "client_icon": null, + "lifespan": 365 +} + +Result will be a long-lived access token: + +{ + "id": 11, + "type": "result", + "success": true, + "result": "ABCDEFGH" +} + """ import logging import uuid @@ -63,7 +126,8 @@ from aiohttp import web import voluptuous as vol -from homeassistant.auth.models import User, Credentials +from homeassistant.auth.models import User, Credentials, \ + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN from homeassistant.components import websocket_api from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator @@ -83,6 +147,15 @@ vol.Required('type'): WS_TYPE_CURRENT_USER, }) +WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token' +SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + vol.Required('lifespan'): int, # days + vol.Required('client_name'): str, + vol.Optional('client_icon'): str, + }) + RESULT_TYPE_CREDENTIALS = 'credentials' RESULT_TYPE_USER = 'user' @@ -100,6 +173,11 @@ async def async_setup(hass, config): WS_TYPE_CURRENT_USER, websocket_current_user, SCHEMA_WS_CURRENT_USER ) + hass.components.websocket_api.async_register_command( + WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + websocket_create_long_lived_access_token, + SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN + ) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -343,3 +421,27 @@ async def async_get_current_user(user): })) hass.async_create_task(async_get_current_user(connection.user)) + + +@websocket_api.ws_require_user() +@callback +def websocket_create_long_lived_access_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Create or a long-lived access token.""" + async def async_create_long_lived_access_token(user): + """Create or a long-lived access token.""" + refresh_token = await hass.auth.async_create_refresh_token( + user, + client_name=msg['client_name'], + client_icon=msg.get('client_icon'), + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=msg['lifespan'])) + + access_token = hass.auth.async_create_access_token( + refresh_token) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], access_token)) + + hass.async_create_task( + async_create_long_lived_access_token(connection.user)) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 63b2b4408dd8af..765199b256c65e 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +import jwt import pytest import voluptuous as vol @@ -323,7 +324,7 @@ async def test_generating_system_user(hass): async def test_refresh_token_requires_client_for_user(hass): - """Test that we can add a system user.""" + """Test create refresh token for a user with client_id.""" manager = await auth.auth_manager_from_config(hass, [], []) user = MockUser().add_to_auth_manager(manager) assert user.system_generated is False @@ -334,10 +335,14 @@ async def test_refresh_token_requires_client_for_user(hass): token = await manager.async_create_refresh_token(user, CLIENT_ID) assert token is not None assert token.client_id == CLIENT_ID + assert token.token_type == auth_models.TOKEN_TYPE_NORMAL + # default access token expiration + assert token.access_token_expiration == \ + auth_const.ACCESS_TOKEN_EXPIRATION async def test_refresh_token_not_requires_client_for_system_user(hass): - """Test that we can add a system user.""" + """Test create refresh token for a system user w/o client_id.""" manager = await auth.auth_manager_from_config(hass, [], []) user = await manager.async_create_system_user('Hass.io') assert user.system_generated is True @@ -348,6 +353,56 @@ async def test_refresh_token_not_requires_client_for_system_user(hass): token = await manager.async_create_refresh_token(user) assert token is not None assert token.client_id is None + assert token.token_type == auth_models.TOKEN_TYPE_SYSTEM + + +async def test_refresh_token_with_specific_access_token_expiration(hass): + """Test create a refresh token with specific access token expiration.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + token = await manager.async_create_refresh_token( + user, CLIENT_ID, + access_token_expiration=timedelta(days=100)) + assert token is not None + assert token.client_id == CLIENT_ID + assert token.access_token_expiration == timedelta(days=100) + + +async def test_refresh_token_type(hass): + """Test create a refresh token with token type.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, CLIENT_ID, token_type=auth_models.TOKEN_TYPE_SYSTEM) + + token = await manager.async_create_refresh_token( + user, CLIENT_ID, + token_type=auth_models.TOKEN_TYPE_NORMAL) + assert token is not None + assert token.client_id == CLIENT_ID + assert token.token_type == auth_models.TOKEN_TYPE_NORMAL + + +async def test_refresh_token_type_long_lived_access_token(hass): + """Test create a refresh token has long-lived access token type.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN) + + token = await manager.async_create_refresh_token( + user, client_name='GPS LOGGER', client_icon='mdi:home', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN) + assert token is not None + assert token.client_id is None + assert token.client_name == 'GPS LOGGER' + assert token.client_icon == 'mdi:home' + assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN async def test_cannot_deactive_owner(mock_hass): @@ -378,6 +433,88 @@ async def test_remove_refresh_token(mock_hass): ) +async def test_create_access_token(mock_hass): + """Test normal refresh_token's jwt_key keep same after used.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_NORMAL + jwt_key = refresh_token.jwt_key + access_token = manager.async_create_access_token(refresh_token) + assert access_token is not None + assert refresh_token.jwt_key == jwt_key + jwt_payload = jwt.decode(access_token, jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(minutes=30).total_seconds() + + +async def test_create_long_lived_access_token(mock_hass): + """Test refresh_token's jwt_key changed for long-lived access token.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=300)) + assert refresh_token.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + jwt_payload = jwt.decode( + access_token, refresh_token.jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(days=300).total_seconds() + + +async def test_one_long_lived_access_token_per_refresh_token(mock_hass): + """Test one refresh_token can only have one long-lived access token.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + assert refresh_token.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + jwt_key = refresh_token.jwt_key + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + + await manager.async_remove_refresh_token(refresh_token) + assert refresh_token.id not in user.refresh_tokens + rt = await manager.async_validate_access_token(access_token) + assert rt is None, 'Previous issued access token has been invoked' + + refresh_token_2 = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + assert refresh_token_2.id != refresh_token.id + assert refresh_token_2.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token_2 = manager.async_create_access_token(refresh_token_2) + jwt_key_2 = refresh_token_2.jwt_key + + assert access_token != access_token_2 + assert jwt_key != jwt_key_2 + + rt = await manager.async_validate_access_token(access_token_2) + jwt_payload = jwt.decode( + access_token_2, rt.jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token_2.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(days=3000).total_seconds() + + async def test_login_with_auth_module(mock_hass): """Test login as existing user with auth module.""" manager = await auth.auth_manager_from_config(mock_hass, [{ diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 7b9dda6acb39e4..e0fe00bd9d8b91 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant import const +from homeassistant.auth import auth_manager_from_config from homeassistant.auth.models import Credentials from homeassistant.components.auth import RESULT_TYPE_USER from homeassistant.setup import async_setup_component @@ -10,7 +12,8 @@ from . import async_setup_auth -from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser, \ + ensure_auth_manager_loaded async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): @@ -267,3 +270,57 @@ async def test_revoking_refresh_token(hass, aiohttp_client): }) assert resp.status == 400 + + +async def test_ws_long_lived_access_token(hass, hass_ws_client): + """Test generate long-lived access token.""" + hass.auth = await auth_manager_from_config( + hass, provider_configs=[{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name', + }] + }], module_configs=[]) + ensure_auth_manager_loaded(hass.auth) + assert await async_setup_component(hass, 'auth', {'http': {}}) + assert await async_setup_component(hass, 'api', {'http': {}}) + + user = MockUser(id='mock-user').add_to_hass(hass) + cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( + {'username': 'test-user'}) + await hass.auth.async_link_user(user, cred) + + ws_client = await hass_ws_client(hass, hass.auth.async_create_access_token( + await hass.auth.async_create_refresh_token(user, CLIENT_ID))) + + # verify create long-lived access token + await ws_client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + 'client_name': 'GPS Logger', + 'lifespan': 365, + }) + + result = await ws_client.receive_json() + assert result['success'], result + + long_lived_access_token = result['result'] + assert long_lived_access_token is not None + + refresh_token = await hass.auth.async_validate_access_token( + long_lived_access_token) + assert refresh_token.client_id is None + assert refresh_token.client_name == 'GPS Logger' + assert refresh_token.client_icon is None + + # verify long-lived access token can be used as bearer token + api_client = ws_client.client + resp = await api_client.get(const.URL_API) + assert resp.status == 401 + + resp = await api_client.get(const.URL_API, headers={ + 'Authorization': 'Bearer {}'.format(long_lived_access_token) + }) + assert resp.status == 200 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index bb9b643296e678..7cec790c84712f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -34,6 +34,8 @@ async def create_client(hass, access_token=None): auth_ok = await websocket.receive_json() assert auth_ok['type'] == wapi.TYPE_AUTH_OK + # wrap in client + websocket.client = client return websocket return create_client