better authenticarion

This commit is contained in:
2026-08-30 11:33:13 +02:00
parent fcae3569fc
commit ea591fc8fb
10 changed files with 299 additions and 16 deletions
+10 -4
View File
@@ -1,12 +1,19 @@
# Registered Netatmo application: GetNetatmoData v2 # Registered Netatmo application: GetNetatmoData v2
NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152 # Mount every route below this path. Leave empty to serve from /.
NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8 URL_PREFIX=/w
NETATMO_CLIENT_ID=6a93db3d7cdb0e40ea0c2655
NETATMO_CLIENT_SECRET=c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8
NETATMO_REFRESH_TOKEN= NETATMO_REFRESH_TOKEN=
# Rotated OAuth tokens are persisted here with mode 0600.
NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json
# Must exactly match a redirect URI configured for the Netatmo application.
NETATMO_REDIRECT_URI=http://www.suy.nl
# Alternatively, useful for a short-lived test (refresh credentials are preferred): # Alternatively, useful for a short-lived test (refresh credentials are preferred):
# NETATMO_ACCESS_TOKEN= # NETATMO_ACCESS_TOKEN=
# NETATMO_DEVICE_ID= # NETATMO_DEVICE_ID=
RRD_FOLDER=./rrd RRD_FOLDER=/var/lib/rrd
POLL_INTERVAL=600 POLL_INTERVAL=600
START_COLLECTOR=true START_COLLECTOR=true
@@ -16,4 +23,3 @@ MODULE_WIND=Wind
MODULE_BEDROOM=Bedroom MODULE_BEDROOM=Bedroom
MODULE_STUDY=Study MODULE_STUDY=Study
MODULE_LIVING=Living MODULE_LIVING=Living
+1 -1
View File
@@ -3,4 +3,4 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.env .env
rrd/ rrd/
netatmo_tokens.json
+23 -3
View File
@@ -30,9 +30,26 @@ cp .env.example .env
``` ```
Add the client ID and secret for the Netatmo application **GetNetatmoData v2** Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
and a refresh token to `.env`. Netatmo's OAuth authorization step must be used to to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured
obtain the initial refresh token with the `read_station` scope. Secrets are read for that application, then perform the initial `read_station` authorization:
from environment variables and are never stored in an RRD.
```sh
set -a
. ./.env
set +a
.venv/bin/python scripts/get_netatmo_token.py
```
The helper opens Netatmo's consent page. After approval, paste the complete URL
from the browser's address bar back into the prompt. This works even if the local
callback page itself cannot be reached. The script exchanges the authorization
code and writes the token file with mode `0600`.
The service loads `NETATMO_TOKEN_FILE` at startup. Before an access token expires,
it refreshes it and atomically saves both the new access token and any rotated
refresh token. Values in the token file take precedence over initial token values
in `.env`. Client credentials remain environment-only and are never written to
the token file or an RRD.
Export the file and run Flask: Export the file and run Flask:
@@ -53,6 +70,9 @@ scheduler runs inside the service process. Alternatively set
`RRD_FOLDER` configures the database directory (default `./rrd`). The module `RRD_FOLDER` configures the database directory (default `./rrd`). The module
variables in `.env.example` allow the five Netatmo display names to be changed. variables in `.env.example` allow the five Netatmo display names to be changed.
Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint
below `/w`; leave it empty to serve from the site root. When a prefix is set,
open <http://localhost:5000/w/> instead.
## HTTP API ## HTTP API
+29 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
import os import os
from flask import Flask, Response, abort, jsonify, render_template from flask import Flask, Response, abort, jsonify, render_template, url_for
from .collector import Collector from .collector import Collector
from .config import Config from .config import Config
@@ -11,12 +11,34 @@ from .netatmo import NetatmoClient
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
class PrefixMiddleware:
"""Mount a WSGI application below a fixed URL path."""
def __init__(self, application, prefix: str):
self.application = application
self.prefix = prefix
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "")
if path == self.prefix or path.startswith(f"{self.prefix}/"):
environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + self.prefix
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
return self.application(environ, start_response)
start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
return [b"Not Found\n"]
def create_app(test_config: dict | None = None) -> Flask: def create_app(test_config: dict | None = None) -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
if test_config: if test_config:
app.config.update(test_config) app.config.update(test_config)
prefix = app.config["URL_PREFIX"]
app.config["APPLICATION_ROOT"] = prefix or "/"
if prefix:
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix)
logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO")) logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO"))
store = app.config.get("RRD_STORE") or RRDStore( store = app.config.get("RRD_STORE") or RRDStore(
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"] app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
@@ -37,7 +59,12 @@ def create_app(test_config: dict | None = None) -> Flask:
@app.get("/") @app.get("/")
def dashboard(): def dashboard():
return render_template("dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS)) return render_template(
"dashboard.html",
rrd_names=list(SCHEMAS),
periods=list(PERIODS),
graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"),
)
@app.get("/health") @app.get("/health")
def health(): def health():
+11
View File
@@ -9,8 +9,19 @@ def _bool(name: str, default: bool) -> bool:
return default if value is None else value.lower() in {"1", "true", "yes", "on"} return default if value is None else value.lower() in {"1", "true", "yes", "on"}
def _prefix(value: str) -> str:
value = value.strip()
if not value or value == "/":
return ""
return "/" + value.strip("/")
class Config: class Config:
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve() RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
NETATMO_TOKEN_FILE = Path(
os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json"))
).expanduser().resolve()
GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900")) GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900"))
GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240")) GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240"))
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600")) POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600"))
+73 -3
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import json import json
import logging import logging
import os
from pathlib import Path
import tempfile
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@@ -14,6 +17,43 @@ class NetatmoError(RuntimeError):
pass pass
class TokenStore:
"""Persist OAuth tokens atomically in a file readable only by its owner."""
def __init__(self, path: Path | str):
self.path = Path(path)
def load(self) -> dict:
if not self.path.exists():
return {}
try:
with self.path.open(encoding="utf-8") as token_file:
data = json.load(token_file)
except (OSError, ValueError) as exc:
raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc
return data if isinstance(data, dict) else {}
def save(self, tokens: dict) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{self.path.name}.", dir=self.path.parent
)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as token_file:
json.dump(tokens, token_file, indent=2, sort_keys=True)
token_file.write("\n")
token_file.flush()
os.fsync(token_file.fileno())
os.replace(temporary_name, self.path)
except Exception:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
raise
class NetatmoClient: class NetatmoClient:
"""Small dependency-free Netatmo OAuth and Weather API client.""" """Small dependency-free Netatmo OAuth and Weather API client."""
@@ -22,10 +62,14 @@ class NetatmoClient:
self.client_secret = config["NETATMO_CLIENT_SECRET"] self.client_secret = config["NETATMO_CLIENT_SECRET"]
self.refresh_token = config["NETATMO_REFRESH_TOKEN"] self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
self.access_token = config["NETATMO_ACCESS_TOKEN"] self.access_token = config["NETATMO_ACCESS_TOKEN"]
self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"])
self.device_id = config["NETATMO_DEVICE_ID"] self.device_id = config["NETATMO_DEVICE_ID"]
self.token_url = config["NETATMO_TOKEN_URL"] self.token_url = config["NETATMO_TOKEN_URL"]
self.stations_url = config["NETATMO_STATIONS_URL"] self.stations_url = config["NETATMO_STATIONS_URL"]
self._expires_at = 0.0 stored = self.token_store.load()
self.access_token = stored.get("access_token", self.access_token)
self.refresh_token = stored.get("refresh_token", self.refresh_token)
self._expires_at = float(stored.get("expires_at", 0))
@property @property
def configured(self) -> bool: def configured(self) -> bool:
@@ -49,10 +93,37 @@ class NetatmoClient:
} }
).encode() ).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST")) result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
self._accept_tokens(result)
def exchange_code(self, code: str, redirect_uri: str) -> dict:
"""Exchange a first-time OAuth authorization code and persist its tokens."""
data = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
self._accept_tokens(result)
return result
def _accept_tokens(self, result: dict) -> None:
if "access_token" not in result:
raise NetatmoError("Netatmo token response contains no access token")
self.access_token = result["access_token"] self.access_token = result["access_token"]
# Netatmo may rotate refresh tokens; retain the new value for this process.
self.refresh_token = result.get("refresh_token", self.refresh_token) self.refresh_token = result.get("refresh_token", self.refresh_token)
self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60 self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60
persisted = {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self._expires_at,
}
if result.get("scope"):
persisted["scope"] = result["scope"]
self.token_store.save(persisted)
def stations_data(self) -> dict: def stations_data(self) -> dict:
if not self.configured: if not self.configured:
@@ -74,4 +145,3 @@ class NetatmoClient:
self._refresh() self._refresh()
request.headers["Authorization"] = f"Bearer {self.access_token}" request.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request(request) return self._request(request)
+5 -2
View File
@@ -24,14 +24,17 @@
{% endfor %} {% endfor %}
</main> </main>
<script> <script>
const graphUrlTemplate = {{ graph_url_template|tojson }};
const buttons = document.querySelectorAll('button[data-period]'); const buttons = document.querySelectorAll('button[data-period]');
buttons.forEach(button => button.addEventListener('click', () => { buttons.forEach(button => button.addEventListener('click', () => {
buttons.forEach(item => item.classList.toggle('active', item === button)); buttons.forEach(item => item.classList.toggle('active', item === button));
document.querySelectorAll('img[data-name]').forEach(image => { document.querySelectorAll('img[data-name]').forEach(image => {
image.src = `/graph/${image.dataset.name}/${button.dataset.period}?t=${Date.now()}`; const graphUrl = graphUrlTemplate
.replace('__name__', encodeURIComponent(image.dataset.name))
.replace('__period__', encodeURIComponent(button.dataset.period));
image.src = `${graphUrl}?t=${Date.now()}`;
}); });
})); }));
</script> </script>
</body> </body>
</html> </html>
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Perform Netatmo's initial OAuth authorization-code exchange."""
from __future__ import annotations
import argparse
import os
import secrets
import sys
import urllib.parse
import webbrowser
from pathlib import Path
# Allow direct execution from a source checkout without installing the package.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from netatmo_service.netatmo import NetatmoClient, NetatmoError
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--client-id", default=os.getenv("NETATMO_CLIENT_ID"))
parser.add_argument("--client-secret", default=os.getenv("NETATMO_CLIENT_SECRET"))
parser.add_argument(
"--redirect-uri", default=os.getenv("NETATMO_REDIRECT_URI", "http://localhost:8080")
)
parser.add_argument(
"--token-file",
type=Path,
default=Path(os.getenv("NETATMO_TOKEN_FILE", "./rrd/netatmo_tokens.json")),
)
parser.add_argument("--no-browser", action="store_true")
return parser.parse_args()
def authorization_code(value: str, expected_state: str) -> str:
value = value.strip()
if "://" not in value:
return value
query = urllib.parse.parse_qs(urllib.parse.urlparse(value).query)
if query.get("state", [None])[0] != expected_state:
raise ValueError("The callback state does not match; restart authorization")
if "error" in query:
raise ValueError(f"Authorization failed: {query['error'][0]}")
if not query.get("code"):
raise ValueError("The callback URL contains no authorization code")
return query["code"][0]
def main() -> int:
args = arguments()
if not args.client_id or not args.client_secret:
print("Set NETATMO_CLIENT_ID and NETATMO_CLIENT_SECRET first.", file=sys.stderr)
return 2
state = secrets.token_urlsafe(24)
url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode(
{
"client_id": args.client_id,
"redirect_uri": args.redirect_uri,
"scope": "read_station",
"state": state,
"response_type": "code",
}
)
print("\nOpen this URL and approve access:\n")
print(url)
if not args.no_browser:
webbrowser.open(url)
print("\nAfter redirect, paste the complete callback URL (or just its code).")
try:
code = authorization_code(input("> "), state)
config = {
"NETATMO_CLIENT_ID": args.client_id,
"NETATMO_CLIENT_SECRET": args.client_secret,
"NETATMO_REFRESH_TOKEN": "",
"NETATMO_ACCESS_TOKEN": "",
"NETATMO_TOKEN_FILE": args.token_file.expanduser().resolve(),
"NETATMO_DEVICE_ID": "",
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
}
client = NetatmoClient(config)
client.exchange_code(code, args.redirect_uri)
except (EOFError, ValueError, NetatmoError) as exc:
print(f"Token setup failed: {exc}", file=sys.stderr)
return 1
print(f"Tokens saved to {args.token_file} with owner-only permissions.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+49
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from netatmo_service import create_app from netatmo_service import create_app
from netatmo_service.data import extract_samples from netatmo_service.data import extract_samples
from netatmo_service.netatmo import NetatmoClient, TokenStore
class FakeClient: class FakeClient:
@@ -28,6 +29,24 @@ def test_routes():
assert client.get("/graph/windyear").status_code == 200 assert client.get("/graph/windyear").status_code == 200
def test_url_prefix_mounts_all_routes():
prefixed = create_app({
"TESTING": True,
"START_COLLECTOR": False,
"URL_PREFIX": "/w",
"RRD_STORE": FakeStore(),
"NETATMO_CLIENT": FakeClient(),
}).test_client()
response = prefixed.get("/w/")
assert response.status_code == 200
assert b'/w/static/dashboard.css' in response.data
assert b'/w/graph/outdoor/day' in response.data
assert prefixed.get("/w/health").status_code == 200
assert prefixed.get("/w/last/outdoor/humidity").status_code == 200
assert prefixed.get("/w/graph/wind/year").status_code == 200
assert prefixed.get("/").status_code == 404
def test_extract_sample_payload(): def test_extract_sample_payload():
namespace = {} namespace = {}
exec(Path("payload_dict.py").read_text(), namespace) exec(Path("payload_dict.py").read_text(), namespace)
@@ -36,3 +55,33 @@ def test_extract_sample_payload():
assert samples["outdoor"].values["pressure"] == 1004.8 assert samples["outdoor"].values["pressure"] == 1004.8
assert samples["wind"].values["angle"] == 236.0 assert samples["wind"].values["angle"] == 236.0
assert "bedroom" not in samples # unreachable module has no dashboard_data assert "bedroom" not in samples # unreachable module has no dashboard_data
def test_rotated_refresh_token_is_persisted(tmp_path):
token_file = tmp_path / "tokens.json"
config = {
"NETATMO_CLIENT_ID": "client",
"NETATMO_CLIENT_SECRET": "secret",
"NETATMO_REFRESH_TOKEN": "old-refresh",
"NETATMO_ACCESS_TOKEN": "",
"NETATMO_TOKEN_FILE": token_file,
"NETATMO_DEVICE_ID": "",
"NETATMO_TOKEN_URL": "https://example.test/token",
"NETATMO_STATIONS_URL": "https://example.test/stations",
}
client = NetatmoClient(config)
client._request = lambda request: {
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 3600,
}
client._refresh()
stored = TokenStore(token_file).load()
assert stored["access_token"] == "new-access"
assert stored["refresh_token"] == "new-refresh"
assert token_file.stat().st_mode & 0o777 == 0o600
restarted = NetatmoClient(config)
assert restarted.access_token == "new-access"
assert restarted.refresh_token == "new-refresh"
+1 -1
View File
@@ -3,5 +3,5 @@ from netatmo_service import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000) app.run(host="0.0.0.0", port=30225)