OSM-Verknüpfung (Modell A): OAuth2-Fundament für Nutzer-Beiträge
- Tabelle user_osm (access_token verschlüsselt at rest via Fernet, Schlüssel aus JWT_SECRET abgeleitet oder OSM_TOKEN_KEY). - Router /api/osm-auth: authorize (signierter state mit user_id+CSRF), callback (Code-Tausch + OSM-Name holen + speichern), status, unlink. - Profil-UI (Settings): "OSM-Konto verknüpfen" / verknüpft-als / trennen, hundehalter-spezifische Motivation. - cryptography in requirements. - Basis für dog=yes-Beiträge + Gamification/Pro (folgt). Staging-Branch. ENV nötig: OSM_CLIENT_ID, OSM_CLIENT_SECRET (Redirect-URI default staging).
This commit is contained in:
parent
4bc7454258
commit
46caa05020
5 changed files with 237 additions and 0 deletions
167
backend/routes/osm_auth.py
Normal file
167
backend/routes/osm_auth.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""
|
||||
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 ---
|
||||
OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize"
|
||||
OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token"
|
||||
OSM_USER_API = "https://api.openstreetmap.org/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", "/?osm=verknuepft")
|
||||
|
||||
_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()
|
||||
if not row:
|
||||
return {"linked": False}
|
||||
return {"linked": True, "osm_name": row["osm_name"],
|
||||
"osm_uid": row["osm_uid"], "linked_at": row["linked_at"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue