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]>
135 lines
6.6 KiB
JavaScript
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`);
|
|
}
|
|
}
|