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
NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152
NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8
# Mount every route below this path. Leave empty to serve from /.
URL_PREFIX=/w
NETATMO_CLIENT_ID=6a93db3d7cdb0e40ea0c2655
NETATMO_CLIENT_SECRET=c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8
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):
# NETATMO_ACCESS_TOKEN=
# NETATMO_DEVICE_ID=
RRD_FOLDER=./rrd
RRD_FOLDER=/var/lib/rrd
POLL_INTERVAL=600
START_COLLECTOR=true
@@ -16,4 +23,3 @@ MODULE_WIND=Wind
MODULE_BEDROOM=Bedroom
MODULE_STUDY=Study
MODULE_LIVING=Living
+1 -1
View File
@@ -3,4 +3,4 @@ __pycache__/
.pytest_cache/
.env
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**
and a refresh token to `.env`. Netatmo's OAuth authorization step must be used to
obtain the initial refresh token with the `read_station` scope. Secrets are read
from environment variables and are never stored in an RRD.
to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured
for that application, then perform the initial `read_station` authorization:
```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:
@@ -53,6 +70,9 @@ scheduler runs inside the service process. Alternatively set
`RRD_FOLDER` configures the database directory (default `./rrd`). The module
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
+29 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
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 .config import Config
@@ -11,12 +11,34 @@ from .netatmo import NetatmoClient
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:
app = Flask(__name__)
app.config.from_object(Config)
if 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"))
store = app.config.get("RRD_STORE") or RRDStore(
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("/")
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")
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"}
def _prefix(value: str) -> str:
value = value.strip()
if not value or value == "/":
return ""
return "/" + value.strip("/")
class Config:
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
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_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240"))
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600"))
+73 -3
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import tempfile
import time
import urllib.error
import urllib.parse
@@ -14,6 +17,43 @@ class NetatmoError(RuntimeError):
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:
"""Small dependency-free Netatmo OAuth and Weather API client."""
@@ -22,10 +62,14 @@ class NetatmoClient:
self.client_secret = config["NETATMO_CLIENT_SECRET"]
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
self.access_token = config["NETATMO_ACCESS_TOKEN"]
self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"])
self.device_id = config["NETATMO_DEVICE_ID"]
self.token_url = config["NETATMO_TOKEN_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
def configured(self) -> bool:
@@ -49,10 +93,37 @@ class NetatmoClient:
}
).encode()
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"]
# Netatmo may rotate refresh tokens; retain the new value for this process.
self.refresh_token = result.get("refresh_token", self.refresh_token)
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:
if not self.configured:
@@ -74,4 +145,3 @@ class NetatmoClient:
self._refresh()
request.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request(request)
+5 -2
View File
@@ -24,14 +24,17 @@
{% endfor %}
</main>
<script>
const graphUrlTemplate = {{ graph_url_template|tojson }};
const buttons = document.querySelectorAll('button[data-period]');
buttons.forEach(button => button.addEventListener('click', () => {
buttons.forEach(item => item.classList.toggle('active', item === button));
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>
</body>
</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.data import extract_samples
from netatmo_service.netatmo import NetatmoClient, TokenStore
class FakeClient:
@@ -28,6 +29,24 @@ def test_routes():
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():
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["wind"].values["angle"] == 236.0
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()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
app.run(host="0.0.0.0", port=30225)