Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
218 lines
8.4 KiB
TypeScript
218 lines
8.4 KiB
TypeScript
import { cookies } from "next/headers";
|
|
import bcrypt from "bcryptjs";
|
|
import { prisma } from "@/lib/prisma";
|
|
import {
|
|
SESSION_COOKIE,
|
|
SESSION_MAX_AGE_SECONDS,
|
|
ADMIN_SESSION_MAX_AGE_SECONDS,
|
|
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";
|
|
|
|
const BCRYPT_ROUNDS = 12;
|
|
|
|
// Vorberechneter Hash, gegen den bei unbekannter E-Mail-Adresse verglichen
|
|
// wird: so dauert ein Login-Versuch mit falscher E-Mail genauso lange wie
|
|
// einer mit falschem Passwort (kein User-Enumeration ueber Timing).
|
|
const dummyHashPromise: Promise<string> = bcrypt.hash("wabenhain-timing-dummy", BCRYPT_ROUNDS);
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
}
|
|
|
|
/**
|
|
* Prueft ein Passwort gegen den Hash eines (moeglicherweise nicht
|
|
* existierenden) Kontos in konstanter Zeit. Bei `hash === null` wird gegen
|
|
* einen Dummy-Hash verglichen und immer false zurueckgegeben.
|
|
*/
|
|
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
|
|
const effectiveHash = hash ?? (await dummyHashPromise);
|
|
const matches = await bcrypt.compare(password, effectiveHash);
|
|
return matches && hash !== null;
|
|
}
|
|
|
|
/**
|
|
* Erzeugt das Session-JWT inkl. Rolle und sessionVersion. Hinweis: Rolle und
|
|
* Version im Token dienen dem Proxy als schneller Vorfilter — autoritativ
|
|
* ist immer die Datenbank (getCurrentCustomer/getCurrentAdmin).
|
|
*/
|
|
export async function createSession(customerId: string, role: SessionRole, sessionVersion: number): Promise<void> {
|
|
const token = await signSessionToken({ customerId, role, sessionVersion });
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: role === "ADMIN" ? ADMIN_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Loescht nur das Session-Cookie auf diesem Geraet — erhoeht bewusst NICHT
|
|
* sessionVersion (das ist global pro Kunde, nicht pro Geraet/Sitzung). Ein
|
|
* bereits vor dem Logout kopiertes/gestohlenes JWT bliebe dadurch bis zum
|
|
* natuerlichen Ablauf gueltig; das ist der bekannte Kompromiss der
|
|
* Einzel-Zaehler-Architektur (kein separates Session-Token pro Geraet).
|
|
* Fuer den Fall eines vermuteten Diebstahls existiert bewusst "Alle Geraete
|
|
* abmelden" (erhoeht sessionVersion) als eigene, staerkere Aktion — wuerde
|
|
* ein normales Logout dasselbe tun, waeren beide Aktionen identisch und man
|
|
* koennte sich nicht mehr auf nur einem Geraet abmelden, ohne alle anderen
|
|
* aktiven Sitzungen ebenfalls zu beenden.
|
|
*/
|
|
export async function destroySession(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.delete(SESSION_COOKIE);
|
|
}
|
|
|
|
export async function getCurrentCustomer(): Promise<Customer | null> {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
|
if (!token) return null;
|
|
|
|
const payload = await verifySessionToken(token);
|
|
if (!payload) return null;
|
|
|
|
const customer = await prisma.customer.findUnique({ where: { id: payload.customerId } });
|
|
if (!customer) return null;
|
|
|
|
// Session widerrufen (Passwort-Reset, "alle Geraete abmelden", 2FA-Aenderung)?
|
|
// Das Token selbst ist noch gueltig/unmanipuliert, zaehlt aber nicht mehr.
|
|
if (customer.sessionVersion !== payload.sessionVersion) return null;
|
|
|
|
// Ohne diese Pruefung wuerde eine bestehende Session einer admin-gesperrten
|
|
// Kundschaft weiter funktionieren, bis das Cookie natuerlich ablaeuft —
|
|
// die Sperre in /admin/benutzer muss auch aktive Sitzungen sofort beenden.
|
|
if (customer.blockedAt) return null;
|
|
|
|
return customer;
|
|
}
|
|
|
|
/**
|
|
* Autoritative Admin-Pruefung gegen die Datenbank (nicht nur das JWT-Claim).
|
|
* Muss in jeder Admin-Server-Action und im Admin-Layout aufgerufen werden.
|
|
*/
|
|
export async function getCurrentAdmin(): Promise<Customer | null> {
|
|
const customer = await getCurrentCustomer();
|
|
return customer?.role === "ADMIN" ? customer : null;
|
|
}
|
|
|
|
/** customerId aus einem gueltigen 2FA-Zwischenschritt-Cookie (Passwort ok, Code steht noch aus). */
|
|
export async function getTwoFactorChallengeCustomerId(): Promise<string | null> {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(TWO_FACTOR_CHALLENGE_COOKIE)?.value;
|
|
if (!token) return null;
|
|
return (await verifyTwoFactorChallenge(token))?.customerId ?? null;
|
|
}
|
|
|
|
export async function setTwoFactorChallengeCookie(token: string): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(TWO_FACTOR_CHALLENGE_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 5 * 60,
|
|
});
|
|
}
|
|
|
|
export async function clearTwoFactorChallengeCookie(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.delete(TWO_FACTOR_CHALLENGE_COOKIE);
|
|
}
|
|
|
|
export async function setTwoFactorSetupCookie(token: string): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(TWO_FACTOR_SETUP_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 10 * 60,
|
|
});
|
|
}
|
|
|
|
/** Liest secret + customerId aus dem laufenden 2FA-Einrichtungsversuch, ohne das Cookie zu verbrauchen. */
|
|
export async function getTwoFactorSetup(): Promise<{ customerId: string; secret: string } | null> {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(TWO_FACTOR_SETUP_COOKIE)?.value;
|
|
if (!token) return null;
|
|
return verifyTwoFactorSetup(token);
|
|
}
|
|
|
|
export async function clearTwoFactorSetupCookie(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.delete(TWO_FACTOR_SETUP_COOKIE);
|
|
}
|
|
|
|
export async function setTwoFactorBackupCodesDisplayCookie(token: string): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(TWO_FACTOR_BACKUP_CODES_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 2 * 60,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Liest die frisch generierten Backup-Codes aus (ohne das Cookie zu loeschen —
|
|
* Cookies duerfen waehrend des Server-Component-Renderns nicht mutiert werden,
|
|
* nur innerhalb einer Server Action/Route Handler). Das Cookie hat ohnehin nur
|
|
* eine 2-Minuten-TTL; zusaetzlich raeumt clearTwoFactorBackupCodesDisplayCookie
|
|
* es aktiv auf, sobald der Nutzer die Anzeige bestaetigt.
|
|
*/
|
|
export async function getTwoFactorBackupCodesDisplay(): Promise<string[] | null> {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(TWO_FACTOR_BACKUP_CODES_COOKIE)?.value;
|
|
if (!token) return null;
|
|
const payload = await verifyTwoFactorBackupCodesDisplay(token);
|
|
return payload?.codes ?? null;
|
|
}
|
|
|
|
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);
|
|
}
|