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]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e80aba5ca6
commit
bf0d169015
@@ -0,0 +1,79 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/customer-auth.js — Sitzungs-Logik für echte Kundenkonten (Google-/PayPal-
|
||||
Login), bewusst GETRENNT von functions/_shared/auth.js (das ist die Zugangscode-Schranke für
|
||||
qciga/VanVan) — unterschiedliche Geheimnisse, unterschiedliche Cookies, unterschiedliche
|
||||
Nutzer:innengruppe. Ein Leck bei der einen Sitzungsart darf nie die andere gefährden.
|
||||
|
||||
Gleiches HMAC-Signaturprinzip wie bei der Zugangscode-Schranke (siehe dort für die
|
||||
Begründung), hier bewusst als eigene, kleine Kopie statt geteiltem Code — weniger Kopplung
|
||||
zwischen zwei sicherheitskritischen, aber konzeptionell getrennten Systemen. ===================================================================== */
|
||||
|
||||
export const CUSTOMER_COOKIE_NAME = "vandiy_customer_session";
|
||||
export const CUSTOMER_SESSION_TAGE = 30;
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let bin = "";
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function b64urlDecodeToBytes(str) {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) str += "=";
|
||||
const bin = atob(str);
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
async function hmacKey(secret) {
|
||||
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
||||
}
|
||||
|
||||
/** Signiert eine Kunden-Sitzung für die Datenbank-ID des customers-Eintrags. */
|
||||
export async function signCustomerSession(customerId, secret) {
|
||||
const payload = JSON.stringify({ cid: customerId, exp: Date.now() + CUSTOMER_SESSION_TAGE * 24 * 60 * 60 * 1000 });
|
||||
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||||
const key = await hmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||||
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
|
||||
}
|
||||
|
||||
export async function verifyCustomerSession(token, secret) {
|
||||
if (!token || !token.includes(".")) return null;
|
||||
const [payloadB64, sigB64] = token.split(".");
|
||||
try {
|
||||
const key = await hmacKey(secret);
|
||||
const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64));
|
||||
if (!valid) return null;
|
||||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||
return payload; // { cid, exp }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCookie(request, name) {
|
||||
const header = request.headers.get("Cookie") || "";
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Liefert den eingeloggten customers-Datensatz (aus D1) für die aktuelle Anfrage, oder null.
|
||||
* Zentrale Stelle, die jede Kunden-API-Route nutzt — Kunden-ID kommt NIE direkt vom Client,
|
||||
* immer nur aus der signierten, serverseitig geprüften Sitzung. */
|
||||
export async function getCustomer(request, env) {
|
||||
if (!env.CUSTOMER_SESSION_SECRET || !env.DB) return null;
|
||||
const token = getCookie(request, CUSTOMER_COOKIE_NAME);
|
||||
const payload = token ? await verifyCustomerSession(token, env.CUSTOMER_SESSION_SECRET) : null;
|
||||
if (!payload?.cid) return null;
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT id, email, name, provider, created_at FROM customers WHERE id = ?`
|
||||
).bind(payload.cid).first();
|
||||
return row || null;
|
||||
}
|
||||
|
||||
export function customerCookieHeader(token, maxAgeSekunden) {
|
||||
return `${CUSTOMER_COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSekunden}; HttpOnly; Secure; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearCustomerCookieHeader() {
|
||||
return `${CUSTOMER_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* =====================================================================
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/oauth.js — echte OAuth-/OpenID-Connect-Anmeldung für Google und "Log in
|
||||
with PayPal". Standard-Authorization-Code-Flow MIT PKCE (Proof Key for Code Exchange) — auch
|
||||
wenn der Code-Austausch server-seitig läuft (also ein "confidential client" ist, der das
|
||||
Client-Secret kennt), macht PKCE einen abgefangenen Autorisierungs-Code für Angreifer wertlos,
|
||||
ohne den echten, zufällig erzeugten code_verifier — zusätzliche Verteidigungsebene, siehe
|
||||
functions/api/auth/<provider>/start.js für die Erzeugung.
|
||||
|
||||
WICHTIG: Der Server vertraut NIE einer vom Client mitgeschickten Identität — E-Mail/Name/
|
||||
Nutzer-ID kommen ausschließlich aus der direkten, server-zu-server-Antwort von Google/PayPal
|
||||
selbst (holeNutzerprofil()), nachdem WIR den Autorisierungs-Code gegen ein Zugangstoken
|
||||
getauscht haben. Genau dasselbe Sicherheitsprinzip wie bei der PayPal-Zahlungsprüfung (siehe
|
||||
functions/_shared/paypal.js). ===================================================================== */
|
||||
|
||||
function paypalBasis(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
function paypalSigninBasis(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://www.paypal.com" : "https://www.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
const PROVIDERS = {
|
||||
google: {
|
||||
authorizeUrl: () => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: () => "https://oauth2.googleapis.com/token",
|
||||
userinfoUrl: () => "https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
scope: "openid email profile",
|
||||
clientIdEnvKey: "GOOGLE_CLIENT_ID",
|
||||
clientSecretEnvKey: "GOOGLE_CLIENT_SECRET",
|
||||
// Google akzeptiert den Token-Exchange als klassisches Formular (application/x-www-form-urlencoded).
|
||||
tokenAuthStyle: "body",
|
||||
parseUserinfo: (data) => ({ providerUserId: data.sub, email: data.email, name: data.name || null }),
|
||||
},
|
||||
paypal: {
|
||||
authorizeUrl: (env) => `${paypalSigninBasis(env)}/signin/authorize`,
|
||||
tokenUrl: (env) => `${paypalBasis(env)}/v1/oauth2/token`,
|
||||
userinfoUrl: (env) => `${paypalBasis(env)}/v1/identity/openidconnect/userinfo/?schema=openid`,
|
||||
scope: "openid email profile",
|
||||
// "Log in with PayPal" nutzt bewusst DIESELBEN App-Zugangsdaten wie die PayPal-Zahlungs-
|
||||
// anbindung (functions/_shared/paypal.js) — VanVan muss in ihrer einen PayPal-App im
|
||||
// Entwickler-Dashboard nur zusätzlich die Funktion "Log in with PayPal" aktivieren, statt
|
||||
// eine zweite App anzulegen (siehe Vault-Doku).
|
||||
clientIdEnvKey: "PAYPAL_CLIENT_ID",
|
||||
clientSecretEnvKey: "PAYPAL_CLIENT_SECRET",
|
||||
tokenAuthStyle: "basic",
|
||||
parseUserinfo: (data) => ({ providerUserId: data.user_id || data.payer_id, email: data.email, name: data.name || null }),
|
||||
},
|
||||
};
|
||||
|
||||
export function provider(name) {
|
||||
const p = PROVIDERS[name];
|
||||
if (!p) throw new Error(`Unbekannter OAuth-Anbieter: ${name}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function istProviderKonfiguriert(env, name) {
|
||||
const p = provider(name);
|
||||
return !!(env[p.clientIdEnvKey] && env[p.clientSecretEnvKey]);
|
||||
}
|
||||
|
||||
/** Baut die Weiterleitungs-URL zum Anbieter — state (CSRF-Schutz) und code_challenge (PKCE)
|
||||
* wurden vorher vom Aufrufer erzeugt und in signierten Cookies zwischengespeichert. */
|
||||
export function baueAutorisierungsUrl(env, name, { redirectUri, state, codeChallenge }) {
|
||||
const p = provider(name);
|
||||
const clientId = env[p.clientIdEnvKey];
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
scope: p.scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
if (name === "google") params.set("prompt", "select_account");
|
||||
return `${p.authorizeUrl(env)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Tauscht den Autorisierungs-Code server-seitig gegen ein Zugangstoken — das Client-Secret
|
||||
* verlässt den Server dabei nie, es fließt nur in diesen einen serverseitigen Aufruf ein. */
|
||||
export async function tauscheCodeGegenToken(env, name, { code, redirectUri, codeVerifier }) {
|
||||
const p = provider(name);
|
||||
const clientId = env[p.clientIdEnvKey];
|
||||
const clientSecret = env[p.clientSecretEnvKey];
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
||||
if (p.tokenAuthStyle === "basic") {
|
||||
headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
||||
} else {
|
||||
body.set("client_id", clientId);
|
||||
body.set("client_secret", clientSecret);
|
||||
}
|
||||
const res = await fetch(p.tokenUrl(env), { method: "POST", headers, body: body.toString() });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Token-Austausch bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.access_token) throw new Error(`${name} hat kein Zugangstoken geliefert.`);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Holt Name/E-Mail/Nutzer-ID DIREKT vom Anbieter (server-zu-server, mit dem eben erhaltenen
|
||||
* Zugangstoken) — das ist die einzige Quelle, der wir für die Identität vertrauen. */
|
||||
export async function holeNutzerprofil(env, name, accessToken) {
|
||||
const p = provider(name);
|
||||
const res = await fetch(p.userinfoUrl(env), { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Profilabruf bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const profil = p.parseUserinfo(data);
|
||||
if (!profil.providerUserId || !profil.email) {
|
||||
throw new Error(`${name} hat kein vollständiges Profil geliefert (E-Mail/ID fehlt).`);
|
||||
}
|
||||
return profil;
|
||||
}
|
||||
|
||||
// ── PKCE-Hilfsfunktionen ──
|
||||
function b64url(bytes) {
|
||||
let bin = "";
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function erzeugeZufallswert(laenge = 32) {
|
||||
const bytes = new Uint8Array(laenge);
|
||||
crypto.getRandomValues(bytes);
|
||||
return b64url(bytes);
|
||||
}
|
||||
|
||||
export async function codeChallengeFuer(codeVerifier) {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
||||
return b64url(new Uint8Array(hash));
|
||||
}
|
||||
Reference in New Issue
Block a user