mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Break up components/auth (#15713)
This commit is contained in:
parent
c7f4bdafc0
commit
93d6fb8c60
@ -1,62 +1,5 @@
|
|||||||
"""Component to allow users to login and get tokens.
|
"""Component to allow users to login and get tokens.
|
||||||
|
|
||||||
All requests will require passing in a valid client ID and secret via HTTP
|
|
||||||
Basic Auth.
|
|
||||||
|
|
||||||
# GET /auth/providers
|
|
||||||
|
|
||||||
Return a list of auth providers. Example:
|
|
||||||
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "Local",
|
|
||||||
"id": null,
|
|
||||||
"type": "local_provider",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# POST /auth/login_flow
|
|
||||||
|
|
||||||
Create a login flow. Will return the first step of the flow.
|
|
||||||
|
|
||||||
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
|
||||||
are identified by type and id.
|
|
||||||
|
|
||||||
{
|
|
||||||
"handler": ["local_provider", null]
|
|
||||||
}
|
|
||||||
|
|
||||||
Return value will be a step in a data entry flow. See the docs for data entry
|
|
||||||
flow for details.
|
|
||||||
|
|
||||||
{
|
|
||||||
"data_schema": [
|
|
||||||
{"name": "username", "type": "string"},
|
|
||||||
{"name": "password", "type": "string"}
|
|
||||||
],
|
|
||||||
"errors": {},
|
|
||||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
|
||||||
"handler": ["insecure_example", null],
|
|
||||||
"step_id": "init",
|
|
||||||
"type": "form"
|
|
||||||
}
|
|
||||||
|
|
||||||
# POST /auth/login_flow/{flow_id}
|
|
||||||
|
|
||||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
|
||||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
|
||||||
have type "create_entry" and "result" key will contain an authorization code.
|
|
||||||
|
|
||||||
{
|
|
||||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
|
||||||
"handler": ["insecure_example", null],
|
|
||||||
"result": "411ee2f916e648d691e937ae9344681e",
|
|
||||||
"source": "user",
|
|
||||||
"title": "Example",
|
|
||||||
"type": "create_entry",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# POST /auth/token
|
# POST /auth/token
|
||||||
|
|
||||||
This is an OAuth2 endpoint for granting tokens. We currently support the grant
|
This is an OAuth2 endpoint for granting tokens. We currently support the grant
|
||||||
@ -102,26 +45,20 @@ a limited expiration.
|
|||||||
"token_type": "Bearer"
|
"token_type": "Bearer"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import aiohttp.web
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import data_entry_flow
|
|
||||||
from homeassistant.components.http.ban import process_wrong_login, \
|
|
||||||
log_invalid_auth
|
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.helpers.data_entry_flow import (
|
|
||||||
FlowManagerIndexView, FlowManagerResourceView)
|
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.components.http.view import HomeAssistantView
|
from homeassistant.components.http.ban import log_invalid_auth
|
||||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||||
|
from homeassistant.components.http.view import HomeAssistantView
|
||||||
|
from homeassistant.core import callback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import indieauth
|
from . import indieauth
|
||||||
|
from . import login_flow
|
||||||
|
|
||||||
DOMAIN = 'auth'
|
DOMAIN = 'auth'
|
||||||
DEPENDENCIES = ['http']
|
DEPENDENCIES = ['http']
|
||||||
@ -138,10 +75,6 @@ async def async_setup(hass, config):
|
|||||||
"""Component to allow users to login."""
|
"""Component to allow users to login."""
|
||||||
store_credentials, retrieve_credentials = _create_cred_store()
|
store_credentials, retrieve_credentials = _create_cred_store()
|
||||||
|
|
||||||
hass.http.register_view(AuthProvidersView)
|
|
||||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
|
||||||
hass.http.register_view(
|
|
||||||
LoginFlowResourceView(hass.auth.login_flow, store_credentials))
|
|
||||||
hass.http.register_view(GrantTokenView(retrieve_credentials))
|
hass.http.register_view(GrantTokenView(retrieve_credentials))
|
||||||
hass.http.register_view(LinkUserView(retrieve_credentials))
|
hass.http.register_view(LinkUserView(retrieve_credentials))
|
||||||
|
|
||||||
@ -150,100 +83,11 @@ async def async_setup(hass, config):
|
|||||||
SCHEMA_WS_CURRENT_USER
|
SCHEMA_WS_CURRENT_USER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await login_flow.async_setup(hass, store_credentials)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class AuthProvidersView(HomeAssistantView):
|
|
||||||
"""View to get available auth providers."""
|
|
||||||
|
|
||||||
url = '/auth/providers'
|
|
||||||
name = 'api:auth:providers'
|
|
||||||
requires_auth = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
"""Get available auth providers."""
|
|
||||||
return self.json([{
|
|
||||||
'name': provider.name,
|
|
||||||
'id': provider.id,
|
|
||||||
'type': provider.type,
|
|
||||||
} for provider in request.app['hass'].auth.auth_providers])
|
|
||||||
|
|
||||||
|
|
||||||
class LoginFlowIndexView(FlowManagerIndexView):
|
|
||||||
"""View to create a config flow."""
|
|
||||||
|
|
||||||
url = '/auth/login_flow'
|
|
||||||
name = 'api:auth:login_flow'
|
|
||||||
requires_auth = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
"""Do not allow index of flows in progress."""
|
|
||||||
return aiohttp.web.Response(status=405)
|
|
||||||
|
|
||||||
@RequestDataValidator(vol.Schema({
|
|
||||||
vol.Required('client_id'): str,
|
|
||||||
vol.Required('handler'): vol.Any(str, list),
|
|
||||||
vol.Required('redirect_uri'): str,
|
|
||||||
}))
|
|
||||||
@log_invalid_auth
|
|
||||||
async def post(self, request, data):
|
|
||||||
"""Create a new login flow."""
|
|
||||||
if not indieauth.verify_redirect_uri(data['client_id'],
|
|
||||||
data['redirect_uri']):
|
|
||||||
return self.json_message('invalid client id or redirect uri', 400)
|
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
|
||||||
return await super().post(request)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginFlowResourceView(FlowManagerResourceView):
|
|
||||||
"""View to interact with the flow manager."""
|
|
||||||
|
|
||||||
url = '/auth/login_flow/{flow_id}'
|
|
||||||
name = 'api:auth:login_flow:resource'
|
|
||||||
requires_auth = False
|
|
||||||
|
|
||||||
def __init__(self, flow_mgr, store_credentials):
|
|
||||||
"""Initialize the login flow resource view."""
|
|
||||||
super().__init__(flow_mgr)
|
|
||||||
self._store_credentials = store_credentials
|
|
||||||
|
|
||||||
async def get(self, request, flow_id):
|
|
||||||
"""Do not allow getting status of a flow in progress."""
|
|
||||||
return self.json_message('Invalid flow specified', 404)
|
|
||||||
|
|
||||||
@RequestDataValidator(vol.Schema({
|
|
||||||
'client_id': str
|
|
||||||
}, extra=vol.ALLOW_EXTRA))
|
|
||||||
@log_invalid_auth
|
|
||||||
async def post(self, request, flow_id, data):
|
|
||||||
"""Handle progressing a login flow request."""
|
|
||||||
client_id = data.pop('client_id')
|
|
||||||
|
|
||||||
if not indieauth.verify_client_id(client_id):
|
|
||||||
return self.json_message('Invalid client id', 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self._flow_mgr.async_configure(flow_id, data)
|
|
||||||
except data_entry_flow.UnknownFlow:
|
|
||||||
return self.json_message('Invalid flow specified', 404)
|
|
||||||
except vol.Invalid:
|
|
||||||
return self.json_message('User input malformed', 400)
|
|
||||||
|
|
||||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
|
||||||
# @log_invalid_auth does not work here since it returns HTTP 200
|
|
||||||
# need manually log failed login attempts
|
|
||||||
if result['errors'] is not None and \
|
|
||||||
result['errors'].get('base') == 'invalid_auth':
|
|
||||||
await process_wrong_login(request)
|
|
||||||
return self.json(self._prepare_result_json(result))
|
|
||||||
|
|
||||||
result.pop('data')
|
|
||||||
result['result'] = self._store_credentials(client_id, result['result'])
|
|
||||||
|
|
||||||
return self.json(result)
|
|
||||||
|
|
||||||
|
|
||||||
class GrantTokenView(HomeAssistantView):
|
class GrantTokenView(HomeAssistantView):
|
||||||
"""View to grant tokens."""
|
"""View to grant tokens."""
|
||||||
|
|
||||||
|
172
homeassistant/components/auth/login_flow.py
Normal file
172
homeassistant/components/auth/login_flow.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""HTTP views handle login flow.
|
||||||
|
|
||||||
|
# GET /auth/providers
|
||||||
|
|
||||||
|
Return a list of auth providers. Example:
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Local",
|
||||||
|
"id": null,
|
||||||
|
"type": "local_provider",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# POST /auth/login_flow
|
||||||
|
|
||||||
|
Create a login flow. Will return the first step of the flow.
|
||||||
|
|
||||||
|
Pass in parameter 'client_id' and 'redirect_url' validate by indieauth.
|
||||||
|
|
||||||
|
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
||||||
|
are identified by type and id.
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_id": "https://hassbian.local:8123/",
|
||||||
|
"handler": ["local_provider", null],
|
||||||
|
"redirect_url": "https://hassbian.local:8123/"
|
||||||
|
}
|
||||||
|
|
||||||
|
Return value will be a step in a data entry flow. See the docs for data entry
|
||||||
|
flow for details.
|
||||||
|
|
||||||
|
{
|
||||||
|
"data_schema": [
|
||||||
|
{"name": "username", "type": "string"},
|
||||||
|
{"name": "password", "type": "string"}
|
||||||
|
],
|
||||||
|
"errors": {},
|
||||||
|
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||||
|
"handler": ["insecure_example", null],
|
||||||
|
"step_id": "init",
|
||||||
|
"type": "form"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# POST /auth/login_flow/{flow_id}
|
||||||
|
|
||||||
|
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||||
|
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||||
|
have type "create_entry" and "result" key will contain an authorization code.
|
||||||
|
|
||||||
|
{
|
||||||
|
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||||
|
"handler": ["insecure_example", null],
|
||||||
|
"result": "411ee2f916e648d691e937ae9344681e",
|
||||||
|
"source": "user",
|
||||||
|
"title": "Example",
|
||||||
|
"type": "create_entry",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import aiohttp.web
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.http.ban import process_wrong_login, \
|
||||||
|
log_invalid_auth
|
||||||
|
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||||
|
from homeassistant.components.http.view import HomeAssistantView
|
||||||
|
from homeassistant.helpers.data_entry_flow import (
|
||||||
|
FlowManagerIndexView, FlowManagerResourceView)
|
||||||
|
from . import indieauth
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, store_credentials):
|
||||||
|
"""Component to allow users to login."""
|
||||||
|
hass.http.register_view(AuthProvidersView)
|
||||||
|
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
||||||
|
hass.http.register_view(
|
||||||
|
LoginFlowResourceView(hass.auth.login_flow, store_credentials))
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvidersView(HomeAssistantView):
|
||||||
|
"""View to get available auth providers."""
|
||||||
|
|
||||||
|
url = '/auth/providers'
|
||||||
|
name = 'api:auth:providers'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
"""Get available auth providers."""
|
||||||
|
return self.json([{
|
||||||
|
'name': provider.name,
|
||||||
|
'id': provider.id,
|
||||||
|
'type': provider.type,
|
||||||
|
} for provider in request.app['hass'].auth.auth_providers])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowIndexView(FlowManagerIndexView):
|
||||||
|
"""View to create a config flow."""
|
||||||
|
|
||||||
|
url = '/auth/login_flow'
|
||||||
|
name = 'api:auth:login_flow'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
"""Do not allow index of flows in progress."""
|
||||||
|
return aiohttp.web.Response(status=405)
|
||||||
|
|
||||||
|
@RequestDataValidator(vol.Schema({
|
||||||
|
vol.Required('client_id'): str,
|
||||||
|
vol.Required('handler'): vol.Any(str, list),
|
||||||
|
vol.Required('redirect_uri'): str,
|
||||||
|
}))
|
||||||
|
@log_invalid_auth
|
||||||
|
async def post(self, request, data):
|
||||||
|
"""Create a new login flow."""
|
||||||
|
if not indieauth.verify_redirect_uri(data['client_id'],
|
||||||
|
data['redirect_uri']):
|
||||||
|
return self.json_message('invalid client id or redirect uri', 400)
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
return await super().post(request)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowResourceView(FlowManagerResourceView):
|
||||||
|
"""View to interact with the flow manager."""
|
||||||
|
|
||||||
|
url = '/auth/login_flow/{flow_id}'
|
||||||
|
name = 'api:auth:login_flow:resource'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, flow_mgr, store_credentials):
|
||||||
|
"""Initialize the login flow resource view."""
|
||||||
|
super().__init__(flow_mgr)
|
||||||
|
self._store_credentials = store_credentials
|
||||||
|
|
||||||
|
async def get(self, request, flow_id):
|
||||||
|
"""Do not allow getting status of a flow in progress."""
|
||||||
|
return self.json_message('Invalid flow specified', 404)
|
||||||
|
|
||||||
|
@RequestDataValidator(vol.Schema({
|
||||||
|
'client_id': str
|
||||||
|
}, extra=vol.ALLOW_EXTRA))
|
||||||
|
@log_invalid_auth
|
||||||
|
async def post(self, request, flow_id, data):
|
||||||
|
"""Handle progressing a login flow request."""
|
||||||
|
client_id = data.pop('client_id')
|
||||||
|
|
||||||
|
if not indieauth.verify_client_id(client_id):
|
||||||
|
return self.json_message('Invalid client id', 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._flow_mgr.async_configure(flow_id, data)
|
||||||
|
except data_entry_flow.UnknownFlow:
|
||||||
|
return self.json_message('Invalid flow specified', 404)
|
||||||
|
except vol.Invalid:
|
||||||
|
return self.json_message('User input malformed', 400)
|
||||||
|
|
||||||
|
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||||
|
# @log_invalid_auth does not work here since it returns HTTP 200
|
||||||
|
# need manually log failed login attempts
|
||||||
|
if result['errors'] is not None and \
|
||||||
|
result['errors'].get('base') == 'invalid_auth':
|
||||||
|
await process_wrong_login(request)
|
||||||
|
return self.json(self._prepare_result_json(result))
|
||||||
|
|
||||||
|
result.pop('data')
|
||||||
|
result['result'] = self._store_credentials(client_id, result['result'])
|
||||||
|
|
||||||
|
return self.json(result)
|
@ -8,6 +8,7 @@ async def test_fetch_auth_providers(hass, aiohttp_client):
|
|||||||
"""Test fetching auth providers."""
|
"""Test fetching auth providers."""
|
||||||
client = await async_setup_auth(hass, aiohttp_client)
|
client = await async_setup_auth(hass, aiohttp_client)
|
||||||
resp = await client.get('/auth/providers')
|
resp = await client.get('/auth/providers')
|
||||||
|
assert resp.status == 200
|
||||||
assert await resp.json() == [{
|
assert await resp.json() == [{
|
||||||
'name': 'Example',
|
'name': 'Example',
|
||||||
'type': 'insecure_example',
|
'type': 'insecure_example',
|
||||||
@ -60,3 +61,31 @@ async def test_invalid_username_password(hass, aiohttp_client):
|
|||||||
|
|
||||||
assert step['step_id'] == 'init'
|
assert step['step_id'] == 'init'
|
||||||
assert step['errors']['base'] == 'invalid_auth'
|
assert step['errors']['base'] == 'invalid_auth'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_exist_user(hass, aiohttp_client):
|
||||||
|
"""Test logging in with exist user."""
|
||||||
|
client = await async_setup_auth(hass, aiohttp_client, setup_api=True)
|
||||||
|
cred = await hass.auth.auth_providers[0].async_get_or_create_credentials(
|
||||||
|
{'username': 'test-user'})
|
||||||
|
await hass.auth.async_get_or_create_user(cred)
|
||||||
|
|
||||||
|
resp = await client.post('/auth/login_flow', json={
|
||||||
|
'client_id': CLIENT_ID,
|
||||||
|
'handler': ['insecure_example', None],
|
||||||
|
'redirect_uri': CLIENT_REDIRECT_URI,
|
||||||
|
})
|
||||||
|
assert resp.status == 200
|
||||||
|
step = await resp.json()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
'/auth/login_flow/{}'.format(step['flow_id']), json={
|
||||||
|
'client_id': CLIENT_ID,
|
||||||
|
'username': 'test-user',
|
||||||
|
'password': 'test-pass',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
step = await resp.json()
|
||||||
|
assert step['type'] == 'create_entry'
|
||||||
|
assert len(step['result']) > 1
|
Loading…
x
Reference in New Issue
Block a user