2016-11-25 21:04:06 +00:00
|
|
|
"""Authentication for HTTP component."""
|
2021-05-10 21:30:47 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from datetime import timedelta
|
2016-11-25 21:04:06 +00:00
|
|
|
import logging
|
2019-12-12 15:46:33 +00:00
|
|
|
import secrets
|
2021-05-10 21:30:47 +00:00
|
|
|
from typing import Final
|
2021-04-12 16:32:12 +00:00
|
|
|
from urllib.parse import unquote
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2017-09-28 07:49:35 +00:00
|
|
|
from aiohttp import hdrs
|
2021-05-10 21:30:47 +00:00
|
|
|
from aiohttp.web import Application, Request, StreamResponse, middleware
|
2018-10-25 14:44:57 +00:00
|
|
|
import jwt
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2021-05-10 21:30:47 +00:00
|
|
|
from homeassistant.core import HomeAssistant, callback
|
2018-10-25 14:44:57 +00:00
|
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
2021-01-28 11:06:20 +00:00
|
|
|
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-02-14 15:01:46 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2021-05-10 21:30:47 +00:00
|
|
|
DATA_API_PASSWORD: Final = "api_password"
|
|
|
|
DATA_SIGN_SECRET: Final = "http.auth.sign_secret"
|
|
|
|
SIGN_QUERY_PARAM: Final = "authSig"
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
|
2018-10-25 14:44:57 +00:00
|
|
|
@callback
|
2021-05-10 21:30:47 +00:00
|
|
|
def async_sign_path(
|
|
|
|
hass: HomeAssistant, refresh_token_id: str, path: str, expiration: timedelta
|
|
|
|
) -> str:
|
2018-10-25 14:44:57 +00:00
|
|
|
"""Sign a path for temporary access without auth header."""
|
|
|
|
secret = hass.data.get(DATA_SIGN_SECRET)
|
|
|
|
|
|
|
|
if secret is None:
|
2019-12-12 15:46:33 +00:00
|
|
|
secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex()
|
2018-10-25 14:44:57 +00:00
|
|
|
|
|
|
|
now = dt_util.utcnow()
|
2020-02-28 11:39:29 +00:00
|
|
|
encoded = jwt.encode(
|
2021-04-12 16:32:12 +00:00
|
|
|
{
|
|
|
|
"iss": refresh_token_id,
|
|
|
|
"path": unquote(path),
|
|
|
|
"iat": now,
|
|
|
|
"exp": now + expiration,
|
|
|
|
},
|
2020-02-28 11:39:29 +00:00
|
|
|
secret,
|
|
|
|
algorithm="HS256",
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2021-09-08 03:59:02 +00:00
|
|
|
return f"{path}?{SIGN_QUERY_PARAM}={encoded}"
|
2018-10-25 14:44:57 +00:00
|
|
|
|
|
|
|
|
2018-02-15 21:06:14 +00:00
|
|
|
@callback
|
2021-05-10 21:30:47 +00:00
|
|
|
def setup_auth(hass: HomeAssistant, app: Application) -> None:
|
2018-02-15 21:06:14 +00:00
|
|
|
"""Create auth middleware for the app."""
|
2018-07-01 02:31:36 +00:00
|
|
|
|
2021-05-10 21:30:47 +00:00
|
|
|
async def async_validate_auth_header(request: Request) -> bool:
|
2019-03-11 02:55:36 +00:00
|
|
|
"""
|
|
|
|
Test authorization header against access token.
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
Basic auth_type is legacy code, should be removed with api_password.
|
|
|
|
"""
|
|
|
|
try:
|
2021-05-10 21:30:47 +00:00
|
|
|
auth_type, auth_val = request.headers.get(hdrs.AUTHORIZATION, "").split(
|
|
|
|
" ", 1
|
|
|
|
)
|
2019-03-11 02:55:36 +00:00
|
|
|
except ValueError:
|
|
|
|
# If no space in authorization header
|
|
|
|
return False
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-10-14 21:56:45 +00:00
|
|
|
if auth_type != "Bearer":
|
|
|
|
return False
|
|
|
|
|
|
|
|
refresh_token = await hass.auth.async_validate_access_token(auth_val)
|
|
|
|
|
|
|
|
if refresh_token is None:
|
|
|
|
return False
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-10-14 21:56:45 +00:00
|
|
|
request[KEY_HASS_USER] = refresh_token.user
|
2021-01-28 11:06:20 +00:00
|
|
|
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
|
2019-10-14 21:56:45 +00:00
|
|
|
return True
|
2016-11-27 02:23:28 +00:00
|
|
|
|
2021-05-10 21:30:47 +00:00
|
|
|
async def async_validate_signed_request(request: Request) -> bool:
|
2019-03-11 02:55:36 +00:00
|
|
|
"""Validate a signed request."""
|
|
|
|
secret = hass.data.get(DATA_SIGN_SECRET)
|
2016-11-27 02:23:28 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
if secret is None:
|
|
|
|
return False
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
signature = request.query.get(SIGN_QUERY_PARAM)
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
if signature is None:
|
|
|
|
return False
|
2018-07-01 02:31:36 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
try:
|
|
|
|
claims = jwt.decode(
|
2019-07-31 19:25:30 +00:00
|
|
|
signature, secret, algorithms=["HS256"], options={"verify_iss": False}
|
2019-03-11 02:55:36 +00:00
|
|
|
)
|
|
|
|
except jwt.InvalidTokenError:
|
|
|
|
return False
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if claims["path"] != request.path:
|
2019-03-11 02:55:36 +00:00
|
|
|
return False
|
2017-09-28 07:49:35 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
refresh_token = await hass.auth.async_get_refresh_token(claims["iss"])
|
2018-11-30 16:32:47 +00:00
|
|
|
|
2018-08-14 19:14:12 +00:00
|
|
|
if refresh_token is None:
|
2018-07-01 02:31:36 +00:00
|
|
|
return False
|
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
request[KEY_HASS_USER] = refresh_token.user
|
2021-01-28 11:06:20 +00:00
|
|
|
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
|
2018-07-01 02:31:36 +00:00
|
|
|
return True
|
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
@middleware
|
2021-05-10 21:30:47 +00:00
|
|
|
async def auth_middleware(
|
|
|
|
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
|
|
|
) -> StreamResponse:
|
2019-03-11 02:55:36 +00:00
|
|
|
"""Authenticate as middleware."""
|
|
|
|
authenticated = False
|
2018-10-25 14:44:57 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header(
|
|
|
|
request
|
|
|
|
):
|
2019-03-11 02:55:36 +00:00
|
|
|
authenticated = True
|
2019-10-14 21:56:45 +00:00
|
|
|
auth_type = "bearer token"
|
2018-10-25 14:44:57 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
# We first start with a string check to avoid parsing query params
|
|
|
|
# for every request.
|
2019-07-31 19:25:30 +00:00
|
|
|
elif (
|
|
|
|
request.method == "GET"
|
|
|
|
and SIGN_QUERY_PARAM in request.query
|
|
|
|
and await async_validate_signed_request(request)
|
|
|
|
):
|
2019-03-11 02:55:36 +00:00
|
|
|
authenticated = True
|
2019-10-14 21:56:45 +00:00
|
|
|
auth_type = "signed request"
|
2018-10-25 14:44:57 +00:00
|
|
|
|
2019-10-14 21:56:45 +00:00
|
|
|
if authenticated:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Authenticated %s for %s using %s",
|
2020-08-11 20:57:50 +00:00
|
|
|
request.remote,
|
2019-10-14 21:56:45 +00:00
|
|
|
request.path,
|
|
|
|
auth_type,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2018-10-25 14:44:57 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
request[KEY_AUTHENTICATED] = authenticated
|
|
|
|
return await handler(request)
|
2018-10-25 14:44:57 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
app.middlewares.append(auth_middleware)
|