removed authorization for browser user

This commit is contained in:
2026-08-09 17:25:45 +02:00
parent b477737bad
commit 2677c81d0f
4 changed files with 18 additions and 61 deletions
+13 -19
View File
@@ -29,9 +29,8 @@ python3 -m venv .venv
.venv/bin/python app.py .venv/bin/python app.py
``` ```
On the iPhone, open `http://YOUR-COMPUTER-IP:5000/?key=YOUR_API_KEY` in Safari. On the iPhone, open `http://YOUR-COMPUTER-IP:5000/` in Safari. The iPhone and
The key is saved only for that browser session and removed from the displayed computer must be on the same local network.
URL. The iPhone and computer must be on the same local network.
## Configure buttons and scenes ## Configure buttons and scenes
@@ -71,16 +70,14 @@ Credentials are saved with owner-only permissions in
```yaml ```yaml
bridge: 192.168.1.10 bridge: 192.168.1.10
app_key: your-hue-application-key. # the hue bride api key app_key: your-hue-application-key
api_key: your-dashboard-api-key # the key for this services api
``` ```
The file is ignored by Git and should not be shared. `hue.py register` creates The file is ignored by Git and should not be shared. `hue.py register` creates
or updates it, including a random dashboard API key. You can override values or updates it. You can override the bridge values with environment variables:
for either tool with environment variables:
```sh ```sh
FASTHUE_API_KEY=YOUR_DASHBOARD_KEY HUE_BRIDGE=192.168.1.10 HUE_APP_KEY=YOUR_HUE_KEY .venv/bin/python app.py HUE_BRIDGE=192.168.1.10 HUE_APP_KEY=YOUR_HUE_KEY .venv/bin/python app.py
``` ```
The reverse-proxy path is configured separately in `config.yaml`: The reverse-proxy path is configured separately in `config.yaml`:
@@ -93,17 +90,16 @@ Set it to an empty value when FastHue is served at `/`.
## HTTP API ## HTTP API
Every API request needs the single `api_key` from `secrets.yaml` as a Bearer The dashboard API does not require a key. Do not expose it to untrusted users:
token. Override it with `FASTHUE_API_KEY` or `--api-key` when necessary. every client that can reach it can read and control the configured lights.
```sh ```sh
export FASTHUE_API_KEY='the-api_key-from-secrets.yaml' curl http://localhost:5000/api/groups
curl -H "Authorization: Bearer $FASTHUE_API_KEY" http://localhost:5000/api/groups curl -X POST \
curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \
http://localhost:5000/api/groups/GROUP_UUID/next-scene http://localhost:5000/api/groups/GROUP_UUID/next-scene
curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \ curl -X POST \
http://localhost:5000/api/groups/GROUP_UUID/off http://localhost:5000/api/groups/GROUP_UUID/off
curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \ curl -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"name":"Relax"}' \ -d '{"name":"Relax"}' \
'http://localhost:5000/api/groups/Ground%20floor/scene' 'http://localhost:5000/api/groups/Ground%20floor/scene'
@@ -190,7 +186,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /hue; proxy_set_header X-Forwarded-Prefix /hue;
proxy_set_header Authorization $http_authorization;
} }
} }
``` ```
@@ -199,9 +194,8 @@ The `www-data` group in the systemd unit and `--umask 007` make the socket
readable by Nginx. If your Nginx workers use another group, replace `www-data` readable by Nginx. If your Nginx workers use another group, replace `www-data`
in the unit with that group. Test and reload Nginx with in the unit with that group. Test and reload Nginx with
`sudo nginx -t && sudo systemctl reload nginx`. `sudo nginx -t && sudo systemctl reload nginx`.
Use the HTTPS URL on the iPhone with `/hue/?key=YOUR_API_KEY` once per browser Use the HTTPS URL on the iPhone with `/hue/`. Set `url_prefix` in `config.yaml`
session. Set `url_prefix` in `config.yaml` to another leading-slash prefix when to another leading-slash prefix when needed, and use the same value in Nginx's `location` and
needed, and use the same value in Nginx's `location` and
`X-Forwarded-Prefix`. Keep `X-Forwarded-Prefix`. Keep
`secrets.yaml` readable only by the service account. `secrets.yaml` readable only by the service account.
+3 -27
View File
@@ -4,7 +4,6 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hmac
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -124,7 +123,7 @@ def next_scene(button: dict[str, Any], bridge: HueBridge) -> dict[str, Any]:
return {"name": selected.get("metadata", {}).get("name", "scene")} return {"name": selected.get("metadata", {}).get("name", "scene")}
def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str = "") -> Flask: def create_app(yaml_path: Path, verify_tls: bool, url_prefix: str = "") -> Flask:
app = Flask(__name__) app = Flask(__name__)
if url_prefix: if url_prefix:
# Gunicorn only receives traffic over its private socket from Nginx. # Gunicorn only receives traffic over its private socket from Nginx.
@@ -132,22 +131,11 @@ def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
buttons = read_dashboard(yaml_path) buttons = read_dashboard(yaml_path)
def api_key_required(view: Any) -> Any:
def protected(*args: Any, **kwargs: Any) -> Any:
authorization = request.headers.get("Authorization", "")
expected = f"Bearer {api_key}"
if not hmac.compare_digest(authorization, expected):
return jsonify({"error": "Unauthorized"}), 401
return view(*args, **kwargs)
protected.__name__ = view.__name__
return protected
@app.get("/") @app.get("/")
def index() -> str: def index() -> str:
return render_template("index.html", buttons=buttons) return render_template("index.html", buttons=buttons)
@app.get("/api/groups") @app.get("/api/groups")
@api_key_required
def groups() -> Any: def groups() -> Any:
try: try:
return jsonify({"groups": dashboard_state(buttons, verify_tls)}) return jsonify({"groups": dashboard_state(buttons, verify_tls)})
@@ -155,7 +143,6 @@ def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str
return jsonify({"error": str(exc)}), 503 return jsonify({"error": str(exc)}), 503
@app.post("/api/groups/<group_id>/next-scene") @app.post("/api/groups/<group_id>/next-scene")
@api_key_required
def activate_next_scene(group_id: str) -> Any: def activate_next_scene(group_id: str) -> Any:
try: try:
button = next(item for item in buttons if item["id"] == group_id) button = next(item for item in buttons if item["id"] == group_id)
@@ -168,7 +155,6 @@ def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str
return jsonify({"error": str(exc)}), 503 return jsonify({"error": str(exc)}), 503
@app.post("/api/groups/<group_id>/off") @app.post("/api/groups/<group_id>/off")
@api_key_required
def turn_group_off(group_id: str) -> Any: def turn_group_off(group_id: str) -> Any:
try: try:
button = next(item for item in buttons if item["id"] == group_id) button = next(item for item in buttons if item["id"] == group_id)
@@ -182,7 +168,6 @@ def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str
return jsonify({"error": str(exc)}), 503 return jsonify({"error": str(exc)}), 503
@app.post("/api/groups/<group_name>/scene") @app.post("/api/groups/<group_name>/scene")
@api_key_required
def activate_group_scene(group_name: str) -> Any: def activate_group_scene(group_name: str) -> Any:
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
scene_name = payload.get("name") scene_name = payload.get("name")
@@ -226,12 +211,8 @@ def configured_url_prefix(path: Path = APP_CONFIG) -> str:
def create_gunicorn_app() -> Flask: def create_gunicorn_app() -> Flask:
"""Gunicorn application factory; configuration is read from environment/files.""" """Gunicorn application factory; configuration is read from environment/files."""
config_path = Path(os.environ.get("FASTHUE_DASHBOARD_CONFIG", ROOT / "hue.yaml")) config_path = Path(os.environ.get("FASTHUE_DASHBOARD_CONFIG", ROOT / "hue.yaml"))
secrets_config = load_config(Path(os.environ.get("FASTHUE_SECRETS_FILE", DEFAULT_CONFIG)).expanduser())
api_key = os.environ.get("FASTHUE_API_KEY") or secrets_config.get("api_key")
if not api_key:
raise RuntimeError("add api_key to secrets.yaml or set FASTHUE_API_KEY")
verify_tls = os.environ.get("FASTHUE_VERIFY_TLS", "").casefold() in {"1", "true", "yes"} verify_tls = os.environ.get("FASTHUE_VERIFY_TLS", "").casefold() in {"1", "true", "yes"}
return create_app(config_path, verify_tls, api_key, configured_url_prefix()) return create_app(config_path, verify_tls, configured_url_prefix())
# Older Gunicorn releases do not support --factory. Expose a WSGI callable at # Older Gunicorn releases do not support --factory. Expose a WSGI callable at
@@ -244,19 +225,14 @@ def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=ROOT / "hue.yaml", help="Dashboard YAML file") parser.add_argument("--config", type=Path, default=ROOT / "hue.yaml", help="Dashboard YAML file")
parser.add_argument("--verify-tls", action="store_true", help="Verify the Hue bridge certificate") parser.add_argument("--verify-tls", action="store_true", help="Verify the Hue bridge certificate")
parser.add_argument("--api-key", help="Required Bearer token for the dashboard API")
parser.add_argument("--url-prefix", help="Override the reverse-proxy URL prefix from config.yaml") parser.add_argument("--url-prefix", help="Override the reverse-proxy URL prefix from config.yaml")
parser.add_argument("--host", default="0.0.0.0", help="Listen address") parser.add_argument("--host", default="0.0.0.0", help="Listen address")
parser.add_argument("--port", type=int, default=5000, help="Listen port") parser.add_argument("--port", type=int, default=5000, help="Listen port")
args = parser.parse_args() args = parser.parse_args()
secrets_config = load_config(Path(os.environ.get("FASTHUE_SECRETS_FILE", DEFAULT_CONFIG)).expanduser())
api_key = args.api_key or os.environ.get("FASTHUE_API_KEY") or secrets_config.get("api_key")
if not api_key:
parser.error("add api_key to secrets.yaml, set FASTHUE_API_KEY, or supply --api-key")
url_prefix = args.url_prefix.rstrip("/") if args.url_prefix is not None else configured_url_prefix() url_prefix = args.url_prefix.rstrip("/") if args.url_prefix is not None else configured_url_prefix()
if url_prefix and not url_prefix.startswith("/"): if url_prefix and not url_prefix.startswith("/"):
parser.error("--url-prefix must start with /, for example /hue") parser.error("--url-prefix must start with /, for example /hue")
create_app(args.config, args.verify_tls, api_key, url_prefix).run(host=args.host, port=args.port, debug=False) create_app(args.config, args.verify_tls, url_prefix).run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__": if __name__ == "__main__":
+1 -3
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import argparse import argparse
import json import json
import secrets
import ssl import ssl
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
@@ -229,9 +228,8 @@ def register(args: argparse.Namespace) -> None:
**existing, **existing,
"bridge": bridge.host, "bridge": bridge.host,
"app_key": app_key, "app_key": app_key,
"api_key": existing.get("api_key", secrets.token_urlsafe(32)),
}) })
print(f"Saved bridge address, application key, and dashboard API key in {config_path}") print(f"Saved bridge address and application key in {config_path}")
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
+1 -12
View File
@@ -1,20 +1,9 @@
const status = document.querySelector('#status'); const status = document.querySelector('#status');
const buttons = [...document.querySelectorAll('.group-button')]; const buttons = [...document.querySelectorAll('.group-button')];
const apiPrefix = document.body.dataset.apiPrefix; const apiPrefix = document.body.dataset.apiPrefix;
const url = new URL(window.location.href);
const suppliedKey = url.searchParams.get('key');
if (suppliedKey) {
sessionStorage.setItem('fasthue-api-key', suppliedKey);
url.searchParams.delete('key');
history.replaceState({}, '', url);
}
const apiKey = sessionStorage.getItem('fasthue-api-key');
function apiFetch(path, options = {}) { function apiFetch(path, options = {}) {
return fetch(path, { return fetch(path, options);
...options,
headers: { ...options.headers, Authorization: `Bearer ${apiKey || ''}` },
});
} }
function showStatus(message, persistent = false) { function showStatus(message, persistent = false) {