diff --git a/backend/database.py b/backend/database.py index 08ac7db..ef3b9fa 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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, diff --git a/backend/main.py b/backend/main.py index e954c83..465231d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"]) diff --git a/backend/requirements.txt b/backend/requirements.txt index 414ec32..d45b6f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/osm_auth.py b/backend/routes/osm_auth.py new file mode 100644 index 0000000..0de97b8 --- /dev/null +++ b/backend/routes/osm_auth.py @@ -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"} diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 1cfbe70..e87d6e0 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -672,6 +672,13 @@ window.Page_settings = (() => { +