Files
qciganoandClaude Sonnet 5 bf0d169015 Echtes Google-/PayPal-Login: OAuth 2.0 + PKCE, eigene Kundendatenbank, DSGVO-Selbstbedienung
Kundenkonten sind jetzt genauso echt wie die PayPal-Zahlung: server-geprüftes OAuth 2.0 mit
PKCE für Google und "Log in with PayPal" (functions/_shared/oauth.js, oauth-handlers.js),
neue D1-Tabelle "customers" (bewusst ohne Passwort-Feld), eigene von der Zugangscode-Schranke
getrennte Sitzungs-Logik (customer-auth.js). Echte DSGVO-Rechte direkt im Kontobereich:
Daten herunterladen (Art. 15/20) und Konto unwiderruflich löschen (Art. 17), Bestellungen
bleiben aus gesetzlichen Gründen erhalten. Datenschutzerklärung entsprechend ergänzt.

Ohne echte Google-/PayPal-Zugangsdaten zeigt der Login-Button ehrlich einen
"noch nicht eingerichtet"-Hinweis statt eine Anmeldung vorzutäuschen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 01:45:55 +02:00

135 lines
6.6 KiB
JavaScript

/* =====================================================================
functions/_shared/oauth-handlers.js — gemeinsame Start-/Callback-Logik für alle OAuth-
Anbieter (Google, PayPal), von den dünnen Routen unter functions/api/auth/<provider>/start.js
bzw. callback.js aufgerufen. So gibt es die sicherheitskritische Logik (State-Prüfung, PKCE,
Sitzungserstellung) nur EINMAL, nicht einmal pro Anbieter dupliziert. ===================================================================== */
import {
erzeugeZufallswert,
codeChallengeFuer,
baueAutorisierungsUrl,
istProviderKonfiguriert,
tauscheCodeGegenToken,
holeNutzerprofil,
} from "./oauth.js";
import { signCustomerSession, customerCookieHeader, CUSTOMER_SESSION_TAGE } from "./customer-auth.js";
const STATE_COOKIE = "vandiy_oauth_state";
const VERIFIER_COOKIE = "vandiy_oauth_verifier";
const LANG_COOKIE = "vandiy_oauth_lang";
const OAUTH_TEMP_MAXAGE = 600; // 10 Minuten — reicht locker für den Login-Vorgang, hält aber die
// Angriffsfläche klein, falls die Cookies je "hängen bleiben".
const ERLAUBTE_SPRACHEN = ["", "en", "ch", "fr"]; // "" = Deutsch (kein Präfix)
function getCookieRaw(request, name) {
const header = request.headers.get("Cookie") || "";
const m = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return m ? decodeURIComponent(m[1]) : null;
}
function langPrefix(lang) {
return ERLAUBTE_SPRACHEN.includes(lang) ? lang : "";
}
/** GET /api/auth/:provider/start — leitet zum Anbieter weiter, nachdem State + PKCE-Verifier in
* kurzlebigen, signierten Cookies zwischengespeichert wurden. */
export async function starteOAuth(context, providerName) {
const { request, env } = context;
const url = new URL(request.url);
const lang = langPrefix(url.searchParams.get("lang") || "");
if (!istProviderKonfiguriert(env, providerName)) {
return Response.redirect(`${url.origin}/${lang ? lang + "/" : ""}konto/?login_error=${providerName}_unconfigured`, 302);
}
const state = erzeugeZufallswert(24);
const codeVerifier = erzeugeZufallswert(48);
const codeChallenge = await codeChallengeFuer(codeVerifier);
const redirectUri = `${url.origin}/api/auth/${providerName}/callback`;
const authUrl = baueAutorisierungsUrl(env, providerName, { redirectUri, state, codeChallenge });
const cookiePath = `/api/auth/${providerName}`;
const headers = new Headers({ Location: authUrl });
headers.append("Set-Cookie", `${STATE_COOKIE}=${state}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
headers.append("Set-Cookie", `${VERIFIER_COOKIE}=${codeVerifier}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
headers.append("Set-Cookie", `${LANG_COOKIE}=${lang}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
return new Response(null, { status: 302, headers });
}
/** GET /api/auth/:provider/callback — prüft State (CSRF-Schutz), tauscht den Code gegen ein
* Zugangstoken, holt das echte Profil direkt vom Anbieter, legt bei Bedarf einen neuen
* Kunden-Datensatz an (oder aktualisiert den bestehenden) und setzt die echte Sitzung. */
export async function verarbeiteOAuthCallback(context, providerName) {
const { request, env } = context;
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const errorParam = url.searchParams.get("error");
const lang = langPrefix(getCookieRaw(request, LANG_COOKIE) || "");
const kontoPfad = `${url.origin}/${lang ? lang + "/" : ""}konto/`;
const cookiePath = `/api/auth/${providerName}`;
function loescheTempCookies(headers) {
headers.append("Set-Cookie", `${STATE_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
headers.append("Set-Cookie", `${VERIFIER_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
headers.append("Set-Cookie", `${LANG_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
}
function fehlerRedirect(grund) {
const headers = new Headers({ Location: `${kontoPfad}?login_error=${grund}` });
loescheTempCookies(headers);
return new Response(null, { status: 302, headers });
}
// Nutzer:in hat bei Google/PayPal abgebrochen/abgelehnt — kein Fehler, nur kein Login.
if (errorParam) return fehlerRedirect(`${providerName}_denied`);
if (!code || !state) return fehlerRedirect(`${providerName}_invalid`);
// ── CSRF-Schutz: der State aus der Weiterleitungs-URL MUSS exakt zum eigenen, kurz zuvor
// gesetzten Cookie passen — sonst könnte jemand einen fremden Autorisierungs-Code
// "unterschieben" (klassischer OAuth-CSRF-Angriff). ──
const stateCookie = getCookieRaw(request, STATE_COOKIE);
const verifierCookie = getCookieRaw(request, VERIFIER_COOKIE);
if (!stateCookie || stateCookie !== state || !verifierCookie) {
return fehlerRedirect(`${providerName}_state_mismatch`);
}
if (!env.DB || !env.CUSTOMER_SESSION_SECRET) {
return fehlerRedirect(`${providerName}_backend_unconfigured`);
}
try {
const redirectUri = `${url.origin}/api/auth/${providerName}/callback`;
const accessToken = await tauscheCodeGegenToken(env, providerName, { code, redirectUri, codeVerifier: verifierCookie });
// Einzige Quelle für die Identität: die direkte, server-zu-server-Antwort vom Anbieter.
const profil = await holeNutzerprofil(env, providerName, accessToken);
const now = new Date().toISOString();
const bestehend = await env.DB.prepare(
`SELECT id FROM customers WHERE provider = ? AND provider_user_id = ?`
).bind(providerName, profil.providerUserId).first();
let customerId;
if (bestehend) {
customerId = bestehend.id;
await env.DB.prepare(`UPDATE customers SET email = ?, name = ?, updated_at = ?, last_login_at = ? WHERE id = ?`)
.bind(profil.email, profil.name, now, now, customerId)
.run();
} else {
const insert = await env.DB.prepare(
`INSERT INTO customers (created_at, updated_at, last_login_at, provider, provider_user_id, email, name) VALUES (?,?,?,?,?,?,?)`
)
.bind(now, now, now, providerName, profil.providerUserId, profil.email, profil.name)
.run();
customerId = insert.meta.last_row_id;
}
const sessionToken = await signCustomerSession(customerId, env.CUSTOMER_SESSION_SECRET);
const headers = new Headers({ Location: `${url.origin}/${lang ? lang + "/" : ""}konto/angemeldet/` });
headers.append("Set-Cookie", customerCookieHeader(sessionToken, CUSTOMER_SESSION_TAGE * 24 * 60 * 60));
loescheTempCookies(headers);
return new Response(null, { status: 302, headers });
} catch (err) {
return fehlerRedirect(`${providerName}_failed`);
}
}