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:
rene 2026-06-03 21:14:36 +02:00
parent 4bc7454258
commit 46caa05020
5 changed files with 237 additions and 0 deletions

View file

@ -356,6 +356,18 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
-- OSM-Account-Verknüpfung (OAuth2) je Nutzer Basis für OSM-Beiträge
-- ("Hund war willkommen" dog=yes) + spätere Gamification/Pro-Freischaltung.
-- access_token verschlüsselt at rest (token_enc).
CREATE TABLE IF NOT EXISTS user_osm (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
osm_uid INTEGER NOT NULL,
osm_name TEXT NOT NULL,
token_enc TEXT NOT NULL,
scopes TEXT,
linked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- VERLORENE HUNDE
CREATE TABLE IF NOT EXISTS lost_dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -227,6 +227,7 @@ from routes.walks import router as walks_router
from routes.events import router as events_router
from routes.sitting import router as sitting_router
from routes.osm import router as osm_router
from routes.osm_auth import router as osm_auth_router
from routes.forum import router as forum_router
from routes.lost import router as lost_router
from routes.knigge import router as knigge_router
@ -292,6 +293,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre
app.include_router(events_router, prefix="/api/events", tags=["Events"])
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
app.include_router(osm_auth_router, prefix="/api/osm-auth", tags=["OSM-Auth"])
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
app.include_router(social_router, prefix="/api/social", tags=["Social"])
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])

View file

@ -7,6 +7,7 @@ pydantic[email]==2.10.6
bcrypt==4.3.0
PyJWT==2.10.1
httpx==0.28.1
cryptography==44.0.0
openai==1.59.2
anthropic==0.49.0
pywebpush==2.0.0

167
backend/routes/osm_auth.py Normal file
View 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"}

View file

@ -672,6 +672,13 @@ window.Page_settings = (() => {
</div>
</div>
<div class="card mb-4">
<div class="by-card-section-header">OpenStreetMap die Karte mitverbessern</div>
<div id="settings-osm-body" class="p-4">
<div class="text-sm-muted">Lädt</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
@ -925,6 +932,54 @@ window.Page_settings = (() => {
});
}).catch(() => {});
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
(function _osmLink() {
const el = document.getElementById('settings-osm-body');
if (!el) return;
API.get('/osm-auth/status').then(st => {
if (st.linked) {
el.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
</div>
<button id="settings-osm-unlink"
style="margin-top:var(--space-3);background:none;border:none;
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
Verknüpfung trennen
</button>`;
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
_osmLink();
});
} else {
el.innerHTML = `
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
willkommen war das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
keine Werbung.
</p>
<button id="settings-osm-link"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:none;background:var(--c-primary);color:#fff;
font-size:var(--text-sm);font-weight:600;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
OSM-Konto verknüpfen
</button>`;
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
try {
const r = await API.get('/osm-auth/authorize');
if (r.authorize_url) window.location.href = r.authorize_url;
} catch (e) {
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
}
});
}
}).catch(() => { el.innerHTML = '<div class="text-sm-muted">OSM-Status nicht verfügbar.</div>'; });
})();
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');