Break up components/auth (#15713)
parent
c7f4bdafc0
commit
93d6fb8c60
|
@ -1,62 +1,5 @@
|
|||
"""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
|
||||
|
||||
This is an OAuth2 endpoint for granting tokens. We currently support the grant
|
||||
|
@ -102,26 +45,20 @@ a limited expiration.
|
|||
"token_type": "Bearer"
|
||||
}
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
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.core import callback
|
||||
from homeassistant.helpers.data_entry_flow import (
|
||||
FlowManagerIndexView, FlowManagerResourceView)
|
||||
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.view import HomeAssistantView
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import indieauth
|
||||
|
||||
from . import login_flow
|
||||
|
||||
DOMAIN = 'auth'
|
||||
DEPENDENCIES = ['http']
|
||||
|
@ -138,10 +75,6 @@ async def async_setup(hass, config):
|
|||
"""Component to allow users to login."""
|
||||
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(LinkUserView(retrieve_credentials))
|
||||
|
||||
|
@ -150,100 +83,11 @@ async def async_setup(hass, config):
|
|||
SCHEMA_WS_CURRENT_USER
|
||||
)
|
||||
|
||||
await login_flow.async_setup(hass, store_credentials)
|
||||
|
||||
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):
|
||||
"""View to grant tokens."""
|
||||
|
||||
|
|
|
@ -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."""
|
||||
client = await async_setup_auth(hass, aiohttp_client)
|
||||
resp = await client.get('/auth/providers')
|
||||
assert resp.status == 200
|
||||
assert await resp.json() == [{
|
||||
'name': 'Example',
|
||||
'type': 'insecure_example',
|
||||
|
@ -60,3 +61,31 @@ async def test_invalid_username_password(hass, aiohttp_client):
|
|||
|
||||
assert step['step_id'] == 'init'
|
||||
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…
Reference in New Issue