better authenticarion
This commit is contained in:
+29
-2
@@ -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():
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user