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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
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"}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue