- /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.
179 lines
7.6 KiB
Python
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"}
|