diff --git a/docker/home-assistant/.env b/docker/home-assistant/.env index 68a7c1a..e2f9f1f 100644 --- a/docker/home-assistant/.env +++ b/docker/home-assistant/.env @@ -3,7 +3,7 @@ HOST_PORT_PREFIX= # HA TZ=Europe/Warsaw USERDIR=../../dist - +ENV_NAME_PREFIX=dev # OAUTH2-PORXY ## CORE @@ -11,16 +11,15 @@ HA_URL=http://home-assistant:8123 REDIRECT_URL=https://my-proxy.mydomain.mycompany.com/oauth2/callback DOMAIN=my-proxy.mydomain.mycompany.com -COOKIE_DOMAIN=.mydomain.mycompany.com +COOKIE_DOMAIN=my-proxy.mydomain.mycompany.com SERVER_ADDRESS=192.168.1.1 COOKIE_SECRET=1234567890123456 ##AZURE_AD_SANDBOX_NOT_TESTED_YET! AZURE_AD_HA_SECRET=SECRET AZURE_AD_HA_APP=APPLICATION_ID_GUID -AZURE_AD_DISCOVERY=https://login.microsoftonline.com/TENANT_ID/v2.0 +AZURE_AD_ISSUER=https://login.microsoftonline.com/TENANT_ID/v2.0 TENANT=TENANT_ID -AZURE_AD_SCOPE=openid email TENANT_ID/.default ##FACEBOOK FB_HA_SECRET=secret FB_HA_APP=FB_APP_ID diff --git a/docker/home-assistant/docker-compose.dev.override.yaml b/docker/home-assistant/docker-compose.dev.override.yaml deleted file mode 100644 index 166c1a9..0000000 --- a/docker/home-assistant/docker-compose.dev.override.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: '3.4' - -services: - homeassistant: - container_name: dev-home-assistant - ports: - - 8123:8123 diff --git a/docker/home-assistant/docker-compose.yaml b/docker/home-assistant/docker-compose.yaml index ca3e07a..c84d5eb 100644 --- a/docker/home-assistant/docker-compose.yaml +++ b/docker/home-assistant/docker-compose.yaml @@ -6,7 +6,7 @@ x-logging: &loki-logging services: homeassistant: - container_name: home-assistant + container_name: ${ENV_NAME_PREFIX}-home-assistant restart: always image: homeassistant/home-assistant:2022.10.5 @@ -24,8 +24,8 @@ services: # can base on https://github.com/grafana/grafana/issues/52681 # https://developer.okta.com/blog/2022/07/14/add-auth-to-any-app-with-oauth2-proxy #config link https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/overview - image: quay.io/oauth2-proxy/oauth2-proxy:latest - container_name: auth-proxy + image: quay.io/oauth2-proxy/oauth2-proxy:v7.4.0 + container_name: ${ENV_NAME_PREFIX}-auth-proxy restart: always depends_on: - proxy-server @@ -36,12 +36,12 @@ services: environment: # DOC https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/oauth_provider/#azure-auth-provider - OAUTH2_PROXY_SHOW_DEBUG_ON_ERROR=true - - - OAUTH2_PROXY_PROVIDER=facebook - - OAUTH2_PROXY_CLIENT_ID=${FB_HA_APP} - - OAUTH2_PROXY_CLIENT_SECRET=${FB_HA_SECRET} - - OAUTH2_PROXY_SCOPE=openid email public_profile - + - OAUTH2_PROXY_PROVIDER=azure + - OAUTH2_PROXY_AZURE_TENANT=${TENANT} + - OAUTH2_PROXY_CLIENT_ID=${AZURE_AD_HA_APP} + - OAUTH2_PROXY_CLIENT_SECRET=${AZURE_AD_HA_SECRET} + - OAUTH2_PROXY_OIDC_ISSUER_URL=${AZURE_AD_ISSUER} + - OAUTH2_PROXY_OIDC_EMAIL_CLAIM=upn - OAUTH2_PROXY_UPSTREAMS=${HA_URL} - OAUTH2_PROXY_REVERSE_PROXY=true @@ -52,18 +52,21 @@ services: - OAUTH2_PROXY_HTTP_ADDRESS=http://:4180 - OAUTH2_PROXY_REDIRECT_URL=${REDIRECT_URL} - - OAUTH2_PROXY_cookie_domains=${COOKIE_DOMAIN} - - OAUTH2_PROXY_whitelist_domains=${COOKIE_DOMAIN} + #- OAUTH2_PROXY_COOKIE_DOMAINS=${COOKIE_DOMAIN} + #- OAUTH2_PROXY_WHITELIST_DOMAINS=${COOKIE_DOMAIN} - OAUTH2_PROXY_SSL_UPSTREAM_INSECURE_SKIP_VERIFY=true - OAUTH2_PROXY_SSL_INSECURE_SKIP_VERIFY=true - OAUTH2_PROXY_STANDARD_LOGGING=true - OAUTH2_PROXY_AUTH_LOGGING=true - OAUTH2_PROXY_REQUEST_LOGGING=true - OAUTH2_PROXY_ERRORS_TO_INFO_LOG=true - logging: *loki-logging + - OAUTH2_PROXY_PASS_USER_HEADERS=true + - OAUTH2_PROXY_set_xauthrequest=true + - OAUTH2_PROXY_pass_access_token=true + #logging: *loki-logging proxy-server: image: nginx:1.19 - container_name: proxy-server + container_name: ${ENV_NAME_PREFIX}-proxy-server ports: - 443:443 restart: always @@ -73,15 +76,13 @@ services: - ./nginx-config/key.pem.secret:/etc/nginx/ssl/key.pem:ro - ./nginx-config/passw.secret:/etc/nginx/ssl/passw:ro - ./nginx-config/nginx.conf:/etc/nginx/conf.d/default.conf - logging: *loki-logging + #logging: *loki-logging # certbot: # image: certbot/certbot:latest # volumes: # - ${USERDIR}/certbot/www/:/var/www/certbot/:rw # - ${USERDIR}/certbot/conf/:/etc/letsencrypt/:rw - - networks: default: external: diff --git a/docker/home-assistant/home-assistant-run.sh b/docker/home-assistant/home-assistant-run.sh index 2b3a4c4..41c66d2 100644 --- a/docker/home-assistant/home-assistant-run.sh +++ b/docker/home-assistant/home-assistant-run.sh @@ -6,4 +6,4 @@ mkdir -p ../../dist/ha/config cp -a ../../home-assistant-configuration/. ../../dist/ha/config/ -docker-compose -f docker-compose.yaml -f docker-compose.dev.override.yaml --env-file .env.dev up --detach --force-recreate \ No newline at end of file +docker-compose -f docker-compose.yaml --env-file .env.dev up --detach --force-recreate \ No newline at end of file diff --git a/docker/home-assistant/nginx-config/templates/default.conf.template b/docker/home-assistant/nginx-config/templates/default.conf.template index 8c3b79a..fccf311 100644 --- a/docker/home-assistant/nginx-config/templates/default.conf.template +++ b/docker/home-assistant/nginx-config/templates/default.conf.template @@ -1,6 +1,11 @@ # Reverse proxy to oauth2-proxy server { listen 443 ssl; + + proxy_busy_buffers_size 512k; + proxy_buffers 4 512k; + proxy_buffer_size 256k; + server_name ${SERVER_NAME_OAUTH_PROXY}; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; diff --git a/home-assistant-configuration/custom_components/auth_header/__init__.py b/home-assistant-configuration/custom_components/auth_header/__init__.py new file mode 100644 index 0000000..e48089c --- /dev/null +++ b/home-assistant-configuration/custom_components/auth_header/__init__.py @@ -0,0 +1,133 @@ +import logging +from http import HTTPStatus +from ipaddress import ip_address +from typing import OrderedDict +from aiohttp.web import Request, Response +from typing import Any + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components.auth import DOMAIN as AUTH_DOMAIN +from homeassistant.components.auth import indieauth +from homeassistant.components.auth.login_flow import ( + LoginFlowIndexView, + _prepare_result_json, +) +from homeassistant.components.http.ban import log_invalid_auth, process_success_login +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.core import HomeAssistant + +from . import headers + +DOMAIN = "auth_header" +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional( + "username_header", default="X-Forwarded-Preferred-Username" + ): cv.string, + vol.Optional("debug", default=False): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config): + """Register custom view which includes request in context""" + # Because we start after auth, we have access to store_result + store_result = hass.data[AUTH_DOMAIN] + # Remove old LoginFlowIndexView + for route in hass.http.app.router._resources: + if route.canonical == "/auth/login_flow": + _LOGGER.debug("Removed original login_flow route") + hass.http.app.router._resources.remove(route) + _LOGGER.debug("Add new login_flow route") + hass.http.register_view( + RequestLoginFlowIndexView( + hass.auth.login_flow, store_result, config[DOMAIN]["debug"] + ) + ) + + # Inject Auth-Header provider. + providers = OrderedDict() + provider = headers.HeaderAuthProvider( + hass, + hass.auth._store, + config[DOMAIN], + ) + providers[(provider.type, provider.id)] = provider + providers.update(hass.auth._providers) + hass.auth._providers = providers + _LOGGER.debug("Injected auth_header provider") + return True + + +def get_actual_ip(request: Request) -> str: + """Get remote from `request` without considering overrides. This is because + when behind a reverse proxy, hass overrides the .remote attributes with the X-Forwarded-For + value. We still need to check the actual remote though, to verify its from a valid proxy.""" + if isinstance(request._transport_peername, (list, tuple)): + return request._transport_peername[0] + return request._transport_peername + + +class RequestLoginFlowIndexView(LoginFlowIndexView): + + debug: bool + + def __init__(self, flow_mgr, store_result, debug=False) -> None: + super().__init__(flow_mgr, store_result) + self.debug = debug + + @RequestDataValidator( + vol.Schema( + { + vol.Required("client_id"): str, + vol.Required("handler"): vol.Any(str, list), + vol.Required("redirect_uri"): str, + vol.Optional("type", default="authorize"): str, + } + ) + ) + @log_invalid_auth + async def post(self, request: Request, data: dict[str, Any]) -> Response: + """Create a new login flow.""" + client_id: str = data["client_id"] + redirect_uri: str = data["redirect_uri"] + + if not indieauth.verify_client_id(client_id): + return self.json_message("Invalid client id", HTTPStatus.BAD_REQUEST) + + handler: tuple[str, ...] | str + if isinstance(data["handler"], list): + handler = tuple(data["handler"]) + else: + handler = data["handler"] + + try: + _LOGGER.debug(request.headers) + actual_ip = get_actual_ip(request) + _LOGGER.debug("Got actual IP %s", actual_ip) + result = await self._flow_mgr.async_init( + handler, # type: ignore[arg-type] + context={ + "request": request, + "ip_address": ip_address(request.remote), # type: ignore[arg-type] + "conn_ip_address": ip_address(actual_ip), # type: ignore[arg-type] + "credential_only": data.get("type") == "link_user", + "redirect_uri": redirect_uri, + }, + ) + except data_entry_flow.UnknownHandler: + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) + except data_entry_flow.UnknownStep: + return self.json_message( + "Handler does not support init", HTTPStatus.BAD_REQUEST + ) + + return await self._async_flow_result_to_response(request, client_id, result) diff --git a/home-assistant-configuration/custom_components/auth_header/headers.py b/home-assistant-configuration/custom_components/auth_header/headers.py new file mode 100644 index 0000000..bdcaf59 --- /dev/null +++ b/home-assistant-configuration/custom_components/auth_header/headers.py @@ -0,0 +1,156 @@ +"""Header Authentication provider. + +Allow access to users based on a header set by a reverse-proxy. +""" +import logging +from typing import Any, Dict, List, Optional, cast + +import voluptuous as vol +from aiohttp.web_request import Request +from homeassistant.auth.models import Credentials, User, UserMeta +from homeassistant.auth.providers import ( + AUTH_PROVIDERS, + AuthProvider, + LoginFlow, +) +from homeassistant.auth.providers.trusted_networks import ( + InvalidAuthError, + InvalidUserError, + IPAddress, +) +from homeassistant.core import callback + +CONF_USERNAME_HEADER = "username_header" +_LOGGER = logging.getLogger(__name__) + + +@AUTH_PROVIDERS.register("header") +class HeaderAuthProvider(AuthProvider): + """Header Authentication Provider. + + Allow access to users based on a header set by a reverse-proxy. + """ + + DEFAULT_TITLE = "Header Authentication" + + @property + def type(self) -> str: + return "auth_header" + + @property + def support_mfa(self) -> bool: + """Header Authentication Provider does not support MFA.""" + return False + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + assert context is not None + header_name = self.config[CONF_USERNAME_HEADER] + request = cast(Request, context.get("request")) + if header_name not in request.headers: + _LOGGER.info("No header set, returning empty flow") + return HeaderLoginFlow( + self, + None, + [], + cast(IPAddress, context.get("conn_ip_address")), + ) + remote_user = request.headers[header_name].casefold() + # Translate username to id + users = await self.store.async_get_users() + available_users = [ + user for user in users if not user.system_generated and user.is_active + ] + return HeaderLoginFlow( + self, + remote_user, + available_users, + cast(IPAddress, context.get("conn_ip_address")), + ) + + async def async_user_meta_for_credentials( + self, credentials: Credentials + ) -> UserMeta: + """Return extra user metadata for credentials. + + Trusted network auth provider should never create new user. + """ + raise NotImplementedError + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str] + ) -> Credentials: + """Get credentials based on the flow result.""" + user_id = flow_result["user"] + + users = await self.store.async_get_users() + for user in users: + if not user.system_generated and user.is_active and user.id == user_id: + for credential in await self.async_credentials(): + if credential.data["user_id"] == user_id: + return credential + cred = self.async_create_credentials({"user_id": user_id}) + await self.store.async_link_user(user, cred) + return cred + + # We only allow login as exist user + raise InvalidUserError + + @callback + def async_validate_access(self, ip_addr: IPAddress) -> None: + """Make sure the access is from trusted_proxies. + + Raise InvalidAuthError if not. + Raise InvalidAuthError if trusted_proxies is not configured. + """ + if not self.hass.http.trusted_proxies: + _LOGGER.warning("trusted_proxies is not configured") + raise InvalidAuthError("trusted_proxies is not configured") + + if not any( + ip_addr in trusted_network + for trusted_network in self.hass.http.trusted_proxies + ): + _LOGGER.warning("Remote IP not in trusted proxies: %s", ip_addr) + raise InvalidAuthError("Not in trusted_proxies") + + +class HeaderLoginFlow(LoginFlow): + """Handler for the login flow.""" + + def __init__( + self, + auth_provider: HeaderAuthProvider, + remote_user: str, + available_users: List[User], + ip_address: IPAddress, + ) -> None: + """Initialize the login flow.""" + super().__init__(auth_provider) + self._available_users = available_users + self._remote_user = remote_user + self._ip_address = ip_address + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Handle the step of the form.""" + try: + cast(HeaderAuthProvider, self._auth_provider).async_validate_access( + self._ip_address + ) + + except InvalidAuthError as exc: + _LOGGER.debug("invalid auth", exc_info=exc) + return self.async_abort(reason="not_allowed") + + for user in self._available_users: + for cred in user.credentials: + if "username" in cred.data: + if cred.data["username"] == self._remote_user: + return await self.async_finish({"user": user.id}) + if user.name == self._remote_user: + return await self.async_finish({"user": user.id}) + + _LOGGER.debug("no user found") + return self.async_abort(reason="not_allowed") diff --git a/home-assistant-configuration/custom_components/auth_header/manifest.json b/home-assistant-configuration/custom_components/auth_header/manifest.json new file mode 100644 index 0000000..bd01d77 --- /dev/null +++ b/home-assistant-configuration/custom_components/auth_header/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "auth_header", + "name": "HTTP Header Authentication", + "documentation": "https://github.com/BeryJu/hass-auth-header", + "requirements": [], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [ + "auth" + ], + "codeowners": ["@BeryJu"], + "version": "1.4" +}