banyaro/backend/routes/osm_auth.py
rene 684ffa3b46 OSM-Verknüpfung: In-App-Hilfe „Konto erstellen" (umgebungsabhängig)
- /osm-auth/status liefert signup_url + sandbox-Flag (Sandbox-URL auf Staging,
  echte OSM in Prod).
- Settings-OSM-Karte: ausklappbare Hilfe "Noch kein OSM-Konto? Was ist das?"
  mit Erklärung, 3-Schritt-Anleitung, Sandbox-Testphasen-Hinweis und
  "Kostenloses OSM-Konto erstellen"-Link zur richtigen Instanz.
2026-06-03 22:04:42 +02:00

179 lines
7.6 KiB
Python

"""
OSM-Account-Verknüpfung via OAuth2 (Modell A: Beiträge laufen unter dem
eigenen OSM-Account des Nutzers). Basis fürs spätere "Hund war willkommen"
(dog=yes) + Gamification/Pro-Freischaltung.
Flow:
1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die
OSM-Authorize-URL inkl. signiertem `state` (trägt die banyaro-user_id +
CSRF-Nonce, 10 Min gültig) und leitet den Browser dorthin.
2. OSM leitet zurück auf GET /api/osm-auth/callback?code=&state= (ohne JWT —
daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token
verschlüsselt in user_osm speichern, zurück in die App leiten.
3. GET /status zeigt Verknüpfungsstatus, POST /unlink trennt.
ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT.
Token-Schlüssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY überschreibt).
"""
import os
import base64
import hashlib
import logging
from urllib.parse import urlencode
from datetime import datetime, timezone, timedelta
import jwt
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import RedirectResponse
from cryptography.fernet import Fernet, InvalidToken
from database import db
from auth import get_current_user, JWT_SECRET, JWT_ALGO
logger = logging.getLogger(__name__)
router = APIRouter()
# --- OSM-OAuth2-Endpunkte ---
# Konfigurierbar, damit Staging gegen die Dev-Sandbox laufen kann (KEINE echten
# Edits auf der Produktiv-OSM!). Staging-.env:
# OSM_OAUTH_BASE=https://master.apis.dev.openstreetmap.org
# OSM_API_BASE=https://master.apis.dev.openstreetmap.org
OSM_OAUTH_BASE = os.getenv("OSM_OAUTH_BASE", "https://www.openstreetmap.org").rstrip("/")
OSM_API_BASE = os.getenv("OSM_API_BASE", "https://api.openstreetmap.org").rstrip("/")
OSM_AUTHORIZE = OSM_OAUTH_BASE + "/oauth2/authorize"
OSM_TOKEN = OSM_OAUTH_BASE + "/oauth2/token"
OSM_USER_API = OSM_API_BASE + "/api/0.6/user/details.json"
OSM_SCOPES = "read_prefs write_api"
CLIENT_ID = os.getenv("OSM_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "")
REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback")
POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/#settings")
_STATE_TTL_MIN = 10
# Fernet-Schlüssel zur Token-Verschlüsselung: dediziertes OSM_TOKEN_KEY oder
# deterministisch aus JWT_SECRET abgeleitet (kein zusätzliches Secret nötig).
def _fernet() -> Fernet:
raw = os.getenv("OSM_TOKEN_KEY")
if raw:
return Fernet(raw.encode() if isinstance(raw, str) else raw)
key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest())
return Fernet(key)
def _encrypt(token: str) -> str:
return _fernet().encrypt(token.encode()).decode()
def _decrypt(token_enc: str) -> str:
return _fernet().decrypt(token_enc.encode()).decode()
# ------------------------------------------------------------------
# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin)
# ------------------------------------------------------------------
@router.get("/authorize")
async def authorize(user=Depends(get_current_user)):
if not CLIENT_ID:
raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).")
state = jwt.encode(
{"uid": user["id"],
"exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN),
"purpose": "osm-link"},
JWT_SECRET, algorithm=JWT_ALGO,
)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": OSM_SCOPES,
"state": state,
}
url = OSM_AUTHORIZE + "?" + urlencode(params)
return {"authorize_url": url}
# ------------------------------------------------------------------
# GET /callback — OSM leitet hierher zurück (Browser-Redirect, kein JWT)
# ------------------------------------------------------------------
@router.get("/callback")
async def callback(code: str = Query(...), state: str = Query(...)):
# 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung)
try:
payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO])
if payload.get("purpose") != "osm-link":
raise ValueError("falscher state-Zweck")
uid = int(payload["uid"])
except Exception:
raise HTTPException(400, "Ungültiger oder abgelaufener Verknüpfungs-Link.")
# 2) code → access_token tauschen
async with httpx.AsyncClient(timeout=15) as client:
tok = await client.post(OSM_TOKEN, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
})
if tok.status_code != 200:
logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200])
raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.")
access_token = tok.json().get("access_token")
if not access_token:
raise HTTPException(502, "OSM lieferte kein access_token.")
# 3) OSM-Identität holen (uid + Anzeigename)
me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"})
if me.status_code != 200:
raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.")
u = me.json().get("user", {})
osm_uid, osm_name = u.get("id"), u.get("display_name")
if not (osm_uid and osm_name):
raise HTTPException(502, "OSM-Nutzerdaten unvollständig.")
# 4) verschlüsselt speichern (eine Verknüpfung pro banyaro-User)
with db() as conn:
conn.execute(
"""INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
osm_uid=excluded.osm_uid, osm_name=excluded.osm_name,
token_enc=excluded.token_enc, scopes=excluded.scopes,
linked_at=excluded.linked_at""",
(uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES),
)
logger.info("OSM verknüpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid)
return RedirectResponse(POST_LINK_REDIRECT, status_code=302)
# ------------------------------------------------------------------
# GET /status — Verknüpfungsstatus des eingeloggten Nutzers
# ------------------------------------------------------------------
@router.get("/status")
async def status(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?",
(user["id"],)
).fetchone()
# Registrierungs-URL umgebungsabhängig: Sandbox auf Staging, echte OSM in Prod.
base = {
"linked": bool(row),
"signup_url": OSM_OAUTH_BASE + "/user/new",
"sandbox": "dev.openstreetmap" in OSM_OAUTH_BASE,
"configured": bool(CLIENT_ID),
}
if row:
base.update(osm_name=row["osm_name"], osm_uid=row["osm_uid"], linked_at=row["linked_at"])
return base
# ------------------------------------------------------------------
# POST /unlink — Verknüpfung trennen (Token lokal löschen)
# ------------------------------------------------------------------
@router.post("/unlink")
async def unlink(user=Depends(get_current_user)):
with db() as conn:
conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],))
return {"status": "ok"}