feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
This commit is contained in:
1 parent
0753a3bc8a
commit
4d3100e9ba
28 files changed
+802
-121
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user