feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s

This commit is contained in:
Nick Szaunig committed 2026-09-03 13:51:50 +02:00
1 parent 0753a3bc8a
commit 4d3100e9ba
28 files changed
+802 -121

No files matched your search

+32
View File
@@ -8,11 +8,13 @@ import {
TWO_FACTOR_CHALLENGE_COOKIE,
TWO_FACTOR_SETUP_COOKIE,
TWO_FACTOR_BACKUP_CODES_COOKIE,
ENTRA_OAUTH_COOKIE,
signSessionToken,
verifySessionToken,
verifyTwoFactorChallenge,
verifyTwoFactorSetup,
verifyTwoFactorBackupCodesDisplay,
verifyEntraOAuthState,
type SessionRole,
} from "@/lib/session";
import type { Customer } from "@/generated/prisma/client";
@@ -183,3 +185,33 @@ export async function clearTwoFactorBackupCodesDisplayCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_BACKUP_CODES_COOKIE);
}
/** Setzt das kurzlebige Cookie mit PKCE-code_verifier + CSRF-state fuer den laufenden Entra-ID-SSO-Versuch. */
export async function setEntraOAuthCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ENTRA_OAUTH_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
// "lax" reicht hier: der Redirect zurueck von login.microsoftonline.com
// ist eine Top-Level-GET-Navigation, die "lax" bei Cross-Site-Redirects
// ausdruecklich mitsendet (anders als bei einem POST/Formular-Cross-Site-
// Request, wo "lax" das Cookie zurueckhaelt).
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
}
/** Liest state + code_verifier des laufenden Entra-ID-SSO-Versuchs, ohne das Cookie zu loeschen. */
export async function getEntraOAuthCookie(): Promise<{ state: string; codeVerifier: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(ENTRA_OAUTH_COOKIE)?.value;
if (!token) return null;
return verifyEntraOAuthState(token);
}
/** Einmal verwendet (erfolgreich oder nicht) wird das Cookie sofort geloescht — kein Replay eines alten code_verifier/state-Paars. */
export async function clearEntraOAuthCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ENTRA_OAUTH_COOKIE);
}
+7 -1
View File
@@ -13,7 +13,7 @@ export type ContentFieldType =
export interface ContentFieldDef {
key: string;
group: "company" | "home" | "legal";
group: "company" | "home" | "legal" | "social";
labelDe: string;
labelEn: string;
type: ContentFieldType;
@@ -67,6 +67,12 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
// angezeigt, siehe components/site-chrome.tsx Footer()) ---
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
// --- Rechtstexte (lokalisiert) ---
{ key: "legal.impressum", group: "legal", labelDe: "Impressum", labelEn: "Legal notice", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).impressumPage.sections) },
{ key: "legal.datenschutz", group: "legal", labelDe: "Datenschutz", labelEn: "Privacy policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).datenschutzPage.sections) },
+10 -1
View File
@@ -105,21 +105,30 @@ export async function getProductsByIds(
return products.map(normalizeProduct);
}
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
return prisma.harvest.findMany({
where: { isPublished: true },
include: { batches: true },
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
});
}
/**
* Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — gibt null
* zurueck, wenn die Ernte nicht existiert ODER noch nicht veroeffentlicht
* ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
* Ernten ueber ihre Shop-URL nicht erratbar sind.
*/
export async function getHarvestByYearType(
year: number,
type: string
): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> {
return prisma.harvest.findUnique({
const harvest = await prisma.harvest.findUnique({
where: { year_type: { year, type } },
include: { batches: true, products: true },
});
return harvest && harvest.isPublished ? harvest : null;
}
export async function getBatchByCode(
+41
View File
@@ -174,3 +174,44 @@ export async function verifyTwoFactorBackupCodesDisplay(token: string): Promise<
return null;
}
}
// --- Entra-ID-SSO ("Mit Microsoft anmelden"): kurzlebiger Zwischenschritt
// zwischen Redirect zu Microsoft und Rueckkehr am Callback. Haelt den
// PKCE-code_verifier (darf nie an den Client/die URL, sonst waere PKCE
// wirkungslos) und einen zufaelligen state-Wert zur CSRF-Absicherung —
// genau wie beim 2FA-Challenge-Cookie oben ein eigener, separat benannter
// Token-Typ, nicht wiederverwendbar fuer irgendeinen anderen Zweck. ---
export const ENTRA_OAUTH_COOKIE = "wabenhain_entra_oauth";
const ENTRA_OAUTH_TTL_SECONDS = 10 * 60; // 10 Minuten, Microsoft-Login + Redirect zurueck muss darin passen
interface EntraOAuthStatePayload {
state: string;
codeVerifier: string;
}
export async function signEntraOAuthState(state: string, codeVerifier: string): Promise<string> {
return new SignJWT({ type: "entra-oauth-state", state, codeVerifier })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${ENTRA_OAUTH_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function verifyEntraOAuthState(token: string): Promise<EntraOAuthStatePayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (payload.type !== "entra-oauth-state" || typeof payload.state !== "string" || typeof payload.codeVerifier !== "string") {
return null;
}
return { state: payload.state, codeVerifier: payload.codeVerifier };
} catch {
return null;
}
}
+3 -2
View File
@@ -49,10 +49,11 @@ export const footerNav = {
{ href: "/ernten", key: "harvests" },
{ href: "/kontakt", key: "contact" },
],
// "Versand"/"Abholung" bewusst entfernt: beide verlinkten auf dieselbe
// Seite wie "Rechtliches" -> "Versand und Zahlung" (unten,
// /versand-zahlung) — die eine Verlinkung dort reicht.
einkaufen: [
{ href: "/shop", key: "shop" },
{ href: "/versand-zahlung", key: "shipping" },
{ href: "/versand-zahlung", key: "pickup" },
{ href: "/unternehmen", key: "businessCustomers" },
{ href: "/bestellung-verfolgen", key: "trackOrder" },
],