feat(map): add protected operations access
This commit is contained in:
+233
-10
@@ -1,6 +1,10 @@
|
||||
"""Vehicle Map static server and server-side proxy for the vehicle open platform."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import json
|
||||
import os
|
||||
@@ -9,6 +13,7 @@ import threading
|
||||
import time
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@@ -20,11 +25,134 @@ OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5"))
|
||||
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
|
||||
AUTH_PROVIDER = os.getenv("VEHICLE_MAP_AUTH_PROVIDER", "local").strip().lower()
|
||||
LOCAL_ACCESS_CODE = os.getenv("VEHICLE_MAP_ACCESS_CODE", "").strip()
|
||||
SESSION_SECRET = os.getenv("VEHICLE_MAP_SESSION_SECRET", "").encode("utf-8")
|
||||
SESSION_TTL_SECONDS = int(os.getenv("VEHICLE_MAP_SESSION_TTL_SECONDS", "1800"))
|
||||
COOKIE_SECURE = os.getenv("VEHICLE_MAP_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
|
||||
AUTH_CENTER_INTROSPECTION_URL = os.getenv("VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL", "").strip()
|
||||
AUTH_CENTER_CLIENT_TOKEN = os.getenv("VEHICLE_MAP_AUTH_CENTER_CLIENT_TOKEN", "").strip()
|
||||
|
||||
OPERATIONS_READ_SCOPE = "operations:read"
|
||||
SESSION_COOKIE_NAME = "ln_map_session"
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache = {}
|
||||
|
||||
|
||||
class AuthenticationError(RuntimeError):
|
||||
"""The request does not carry a valid operations-read session."""
|
||||
|
||||
|
||||
class AuthenticationUnavailable(RuntimeError):
|
||||
"""The selected authentication provider is not configured."""
|
||||
|
||||
|
||||
class AccessAuthorizer:
|
||||
"""Small provider boundary: replace this class when the auth center contract is ready."""
|
||||
|
||||
def authorize_access_code(self, access_code):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LocalAccessCodeAuthorizer(AccessAuthorizer):
|
||||
def authorize_access_code(self, access_code):
|
||||
if not LOCAL_ACCESS_CODE or not SESSION_SECRET:
|
||||
raise AuthenticationUnavailable("local access-code authentication is not configured")
|
||||
if not hmac.compare_digest(str(access_code or ""), LOCAL_ACCESS_CODE):
|
||||
raise AuthenticationError("invalid access code")
|
||||
return {"subject": "local-access-code", "scopes": [OPERATIONS_READ_SCOPE]}
|
||||
|
||||
|
||||
class AuthCenterAccessCodeAuthorizer(AccessAuthorizer):
|
||||
"""Adapter for an auth-center access-code introspection endpoint.
|
||||
|
||||
The endpoint contract is deliberately small: it receives accessCode and audience,
|
||||
and returns {active, subject, scopes}. No frontend change is needed when enabled.
|
||||
"""
|
||||
|
||||
def authorize_access_code(self, access_code):
|
||||
if not AUTH_CENTER_INTROSPECTION_URL:
|
||||
raise AuthenticationUnavailable("auth-center introspection URL is not configured")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if AUTH_CENTER_CLIENT_TOKEN:
|
||||
headers["Authorization"] = "Bearer " + AUTH_CENTER_CLIENT_TOKEN
|
||||
request = Request(
|
||||
AUTH_CENTER_INTROSPECTION_URL,
|
||||
data=_json_bytes({"accessCode": str(access_code or ""), "audience": "vehicle-map"}),
|
||||
method="POST",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except (HTTPError, URLError, ValueError) as exc:
|
||||
raise AuthenticationUnavailable("auth-center is unavailable") from exc
|
||||
scopes = payload.get("scopes") or []
|
||||
if not payload.get("active") or OPERATIONS_READ_SCOPE not in scopes:
|
||||
raise AuthenticationError("access code does not grant operations read")
|
||||
return {"subject": str(payload.get("subject") or "auth-center"), "scopes": scopes}
|
||||
|
||||
|
||||
def _authorizer():
|
||||
if AUTH_PROVIDER == "local":
|
||||
return LocalAccessCodeAuthorizer()
|
||||
if AUTH_PROVIDER in {"auth-center", "auth_center"}:
|
||||
return AuthCenterAccessCodeAuthorizer()
|
||||
raise AuthenticationUnavailable("unknown authentication provider")
|
||||
|
||||
|
||||
def _b64encode(value):
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64decode(value):
|
||||
padded = str(value) + "=" * (-len(str(value)) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _create_session(principal):
|
||||
if not SESSION_SECRET:
|
||||
raise AuthenticationUnavailable("session signing secret is not configured")
|
||||
payload = {
|
||||
# Incrementing the session format invalidates earlier long-lived sessions.
|
||||
"v": 2,
|
||||
"sub": principal["subject"],
|
||||
"scopes": principal.get("scopes") or [],
|
||||
"exp": int(time.time()) + max(300, SESSION_TTL_SECONDS),
|
||||
}
|
||||
encoded = _b64encode(_json_bytes(payload))
|
||||
signature = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
|
||||
return encoded + "." + signature
|
||||
|
||||
|
||||
def _read_session(cookie_header):
|
||||
cookies = {}
|
||||
for item in str(cookie_header or "").split(";"):
|
||||
if "=" in item:
|
||||
key, value = item.strip().split("=", 1)
|
||||
cookies[key] = value
|
||||
token = cookies.get(SESSION_COOKIE_NAME, "")
|
||||
try:
|
||||
encoded, signature = token.split(".", 1)
|
||||
expected = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
|
||||
if not SESSION_SECRET or not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
payload = json.loads(_b64decode(encoded))
|
||||
if payload.get("v") != 2 or int(payload.get("exp", 0)) <= int(time.time()):
|
||||
return None
|
||||
if OPERATIONS_READ_SCOPE not in (payload.get("scopes") or []):
|
||||
return None
|
||||
return {"subject": str(payload.get("sub") or ""), "scopes": payload["scopes"]}
|
||||
except (ValueError, TypeError, UnicodeDecodeError, binascii.Error, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _session_cookie(value, max_age):
|
||||
secure = "; Secure" if COOKIE_SECURE else ""
|
||||
return f"{SESSION_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}"
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
@@ -73,6 +201,37 @@ def _cached(key, ttl_seconds, loader):
|
||||
return value
|
||||
|
||||
|
||||
def _load_stations():
|
||||
return _cached(
|
||||
"stations",
|
||||
STATION_CACHE_SECONDS,
|
||||
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
|
||||
)
|
||||
|
||||
|
||||
def _public_station(station):
|
||||
"""Public station directory contract. Keep this a positive field allowlist."""
|
||||
fields = (
|
||||
"id", "name", "shortName", "province", "city", "district", "address",
|
||||
"longitude", "latitude", "cooperative",
|
||||
)
|
||||
return {field: station.get(field) for field in fields if station.get(field) is not None}
|
||||
|
||||
|
||||
def _load_public_stations():
|
||||
stations = _load_stations()
|
||||
return {
|
||||
"status": "ok",
|
||||
"access": {"level": "public", "stations": "directory", "vehicles": "restricted", "hydrogen": "restricted"},
|
||||
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"summary": {
|
||||
"totalStations": len(stations),
|
||||
"cooperativeStations": sum(1 for station in stations if station.get("cooperative")),
|
||||
},
|
||||
"stations": [_public_station(station) for station in stations],
|
||||
}
|
||||
|
||||
|
||||
def _load_dashboard():
|
||||
today = time.strftime("%Y-%m-%d", time.localtime())
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
@@ -82,12 +241,7 @@ def _load_dashboard():
|
||||
mileage_future = executor.submit(
|
||||
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
|
||||
)
|
||||
stations_future = executor.submit(
|
||||
_cached,
|
||||
"stations",
|
||||
STATION_CACHE_SECONDS,
|
||||
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
|
||||
)
|
||||
stations_future = executor.submit(_load_stations)
|
||||
vehicles = realtime_future.result()
|
||||
mileage_rows = mileage_future.result()
|
||||
stations = stations_future.result()
|
||||
@@ -138,14 +292,38 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "LingniuVehicleMap/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health":
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/health":
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"service": "vehicle-map",
|
||||
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
||||
"authProvider": AUTH_PROVIDER,
|
||||
})
|
||||
return
|
||||
if self.path == "/api/dashboard":
|
||||
if path == "/api/public/stations":
|
||||
try:
|
||||
payload = _cached("public-stations", DASHBOARD_CACHE_SECONDS, _load_public_stations)
|
||||
self._write_json(200, payload)
|
||||
except Exception as exc: # keep upstream details server-side only
|
||||
self.log_error("public station refresh failed: %s", exc)
|
||||
self._write_json(502, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "加氢站目录暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
if path == "/api/session":
|
||||
principal = _read_session(self.headers.get("Cookie"))
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"authorized": bool(principal),
|
||||
"principal": principal,
|
||||
})
|
||||
return
|
||||
if path == "/api/dashboard":
|
||||
if not self._require_operations_access():
|
||||
return
|
||||
try:
|
||||
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
||||
self._write_json(200, payload)
|
||||
@@ -154,11 +332,54 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
self._write_json(502, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "车辆数据暂时不可用,请稍后重试",
|
||||
"message": "运营数据暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
if path != "/api/session":
|
||||
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length < 1 or content_length > 4096:
|
||||
raise ValueError("invalid request length")
|
||||
payload = json.loads(self.rfile.read(content_length))
|
||||
principal = _authorizer().authorize_access_code(payload.get("accessCode"))
|
||||
session = _create_session(principal)
|
||||
except AuthenticationError:
|
||||
self._write_json(401, {"status": "error", "code": "ACCESS_DENIED", "message": "访问码无效"})
|
||||
return
|
||||
except AuthenticationUnavailable:
|
||||
self._write_json(503, {"status": "error", "code": "AUTH_UNAVAILABLE", "message": "授权服务暂不可用"})
|
||||
return
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
self._write_json(400, {"status": "error", "code": "INVALID_REQUEST", "message": "访问码格式不正确"})
|
||||
return
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"authorized": True,
|
||||
"principal": principal,
|
||||
}, headers={"Set-Cookie": _session_cookie(session, max(300, SESSION_TTL_SECONDS))})
|
||||
|
||||
def do_DELETE(self):
|
||||
if urlparse(self.path).path != "/api/session":
|
||||
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
|
||||
return
|
||||
self._write_json(200, {"status": "ok", "authorized": False}, headers={"Set-Cookie": _session_cookie("", 0)})
|
||||
|
||||
def _require_operations_access(self):
|
||||
if _read_session(self.headers.get("Cookie")):
|
||||
return True
|
||||
self._write_json(401, {
|
||||
"status": "error",
|
||||
"code": "AUTH_REQUIRED",
|
||||
"message": "运营数据需要授权访问",
|
||||
})
|
||||
return False
|
||||
|
||||
def end_headers(self):
|
||||
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
@@ -167,12 +388,14 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||||
super().end_headers()
|
||||
|
||||
def _write_json(self, status, payload):
|
||||
def _write_json(self, status, payload, headers=None):
|
||||
body = _json_bytes(payload)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for name, value in (headers or {}).items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user