- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium, shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker. - Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft. - .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS, KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS, AI_MONTHLY_TOKEN_LIMIT. - CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy, seed, Passwort für craftvia_app, tsc, lint, build, npm run test. - docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets, Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke, Update/Rollback. build-and-push-images.sh baut craftvia-worker. - Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md und Skript-Kommentaren angepasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
import "dotenv/config";
|
|
import { prisma } from "../src/server/db";
|
|
import { consumeToken, issueToken, peekToken, purgeExpiredTokens } from "../src/server/auth-token";
|
|
import { isTokenStillValid } from "../src/server/sessions";
|
|
import { checkRateLimit, RATE_LIMITS, resetRateLimits } from "../src/server/rate-limit";
|
|
import { findAccountByEmail } from "../src/server/auth-selfservice";
|
|
import { closeQueues } from "../src/server/mail/queue";
|
|
import { closeMailProvider } from "../src/server/mail/provider-smtp";
|
|
|
|
/**
|
|
* SEC2 — Abnahmetest der Sicherheitseigenschaften
|
|
* (`npx tsx scripts/test-auth-selfservice.ts`).
|
|
*
|
|
* Geprüft wird, was sich ohne Browser prüfen lässt — die eigentlichen
|
|
* Sicherheitsgarantien:
|
|
* 1. Token: nur der Hash liegt in der DB, nie das Rohtoken.
|
|
* 2. Single-use: zweite Einlösung schlägt fehl.
|
|
* 3. Ablauf: abgelaufene Tokens werden abgewiesen.
|
|
* 4. Neuanforderung entwertet den vorherigen Link.
|
|
* 5. Manipuliertes/unbekanntes Token wird abgewiesen.
|
|
* 6. Typ-Verwechslung (email_change als password_reset) wird abgewiesen.
|
|
* 7. Rate-Limit greift je IP und je Konto.
|
|
* 8. Session-Invalidierung: älteres JWT ungültig, neueres gültig.
|
|
* 9. Enumeration: unbekannte Adresse liefert kein Konto.
|
|
*
|
|
* Die Browser-Abläufe (Reset-Mail → Link → neues Passwort) stehen im Testplan
|
|
* von docs/_certvia-archiv/SEC2-AUTH-SELFSERVICE.md.
|
|
*/
|
|
|
|
let failures = 0;
|
|
function check(name: string, ok: boolean, detail?: string) {
|
|
if (ok) console.log(` ✓ ${name}`);
|
|
else {
|
|
failures++;
|
|
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
}
|
|
|
|
const PRINCIPAL = "sec2-test-principal";
|
|
|
|
async function cleanup() {
|
|
await prisma.authToken.deleteMany({ where: { principalId: { startsWith: PRINCIPAL } } });
|
|
}
|
|
|
|
async function main() {
|
|
await cleanup();
|
|
resetRateLimits();
|
|
|
|
console.log("1) Token wird nur gehasht gespeichert");
|
|
const t1 = await issueToken({
|
|
principalType: "tenant_user",
|
|
principalId: `${PRINCIPAL}-1`,
|
|
tenantId: null,
|
|
type: "password_reset",
|
|
});
|
|
const rows = await prisma.authToken.findMany({ where: { principalId: `${PRINCIPAL}-1` } });
|
|
check("genau ein Token angelegt", rows.length === 1);
|
|
check("Rohtoken steht NICHT in der DB", !rows.some((r) => r.tokenHash === t1.raw));
|
|
check("Hash ist 64 Hex-Zeichen (SHA-256)", /^[0-9a-f]{64}$/.test(rows[0]?.tokenHash ?? ""));
|
|
check("Rohtoken ist ausreichend lang", t1.raw.length >= 40, `${t1.raw.length} Zeichen`);
|
|
|
|
console.log("2) Single-use");
|
|
const first = await consumeToken(t1.raw, "password_reset");
|
|
check("erste Einlösung erfolgreich", first != null);
|
|
const second = await consumeToken(t1.raw, "password_reset");
|
|
check("zweite Einlösung abgewiesen", second === null);
|
|
check("peek nach Verbrauch liefert null", (await peekToken(t1.raw, "password_reset")) === null);
|
|
|
|
console.log("3) Ablauf");
|
|
const t2 = await issueToken({
|
|
principalType: "tenant_user",
|
|
principalId: `${PRINCIPAL}-2`,
|
|
type: "password_reset",
|
|
});
|
|
await prisma.authToken.updateMany({
|
|
where: { principalId: `${PRINCIPAL}-2` },
|
|
data: { expiresAt: new Date(Date.now() - 1000) },
|
|
});
|
|
check("abgelaufenes Token abgewiesen", (await peekToken(t2.raw, "password_reset")) === null);
|
|
|
|
console.log("4) Neuanforderung entwertet den vorherigen Link");
|
|
const t3a = await issueToken({
|
|
principalType: "tenant_user",
|
|
principalId: `${PRINCIPAL}-3`,
|
|
type: "password_reset",
|
|
});
|
|
const t3b = await issueToken({
|
|
principalType: "tenant_user",
|
|
principalId: `${PRINCIPAL}-3`,
|
|
type: "password_reset",
|
|
});
|
|
check("alter Link ungültig", (await peekToken(t3a.raw, "password_reset")) === null);
|
|
check("neuer Link gültig", (await peekToken(t3b.raw, "password_reset")) != null);
|
|
|
|
console.log("5) Unbekanntes/manipuliertes Token");
|
|
check("Zufallswert abgewiesen", (await peekToken("nicht-existent-xyz", "password_reset")) === null);
|
|
check("leeres Token abgewiesen", (await peekToken("", "password_reset")) === null);
|
|
check(
|
|
"manipuliertes Token abgewiesen",
|
|
(await peekToken(t3b.raw.slice(0, -2) + "AA", "password_reset")) === null,
|
|
);
|
|
|
|
console.log("6) Typ-Verwechslung");
|
|
const t4 = await issueToken({
|
|
principalType: "tenant_user",
|
|
principalId: `${PRINCIPAL}-4`,
|
|
type: "email_change",
|
|
newEmail: "neu@example.test",
|
|
});
|
|
check("email_change nicht als password_reset einlösbar", (await peekToken(t4.raw, "password_reset")) === null);
|
|
check("email_change als email_change gültig", (await peekToken(t4.raw, "email_change")) != null);
|
|
|
|
console.log("7) Rate-Limit");
|
|
resetRateLimits();
|
|
const rule = RATE_LIMITS.passwordResetRequest;
|
|
let blockedAt = -1;
|
|
for (let i = 1; i <= rule.limit + 2; i++) {
|
|
const r = checkRateLimit("passwordResetRequest", { ip: "203.0.113.9", account: "a@example.test" });
|
|
if (!r.allowed && blockedAt < 0) blockedAt = i;
|
|
}
|
|
check(`greift nach ${rule.limit} Versuchen`, blockedAt === rule.limit + 1, `blockiert ab ${blockedAt}`);
|
|
const otherIp = checkRateLimit("passwordResetRequest", { ip: "198.51.100.4", account: "b@example.test" });
|
|
check("anderes Konto/IP unberührt", otherIp.allowed);
|
|
const sameAccountOtherIp = checkRateLimit("passwordResetRequest", {
|
|
ip: "198.51.100.5",
|
|
account: "a@example.test",
|
|
});
|
|
check("Kontozähler greift auch von anderer IP", !sameAccountOtherIp.allowed);
|
|
|
|
console.log("8) Session-Invalidierung");
|
|
const mark = new Date("2026-07-30T12:00:00Z");
|
|
const before = Math.floor(mark.getTime() / 1000) - 10;
|
|
const after = Math.floor(mark.getTime() / 1000) + 10;
|
|
check("älteres JWT ungültig", !isTokenStillValid(before, mark));
|
|
check("neueres JWT gültig", isTokenStillValid(after, mark));
|
|
check("ohne Marke immer gültig", isTokenStillValid(before, null));
|
|
check("ohne iat fail-closed", !isTokenStillValid(undefined, mark));
|
|
|
|
console.log("9) Enumeration");
|
|
check(
|
|
"unbekannte Adresse liefert kein Konto",
|
|
(await findAccountByEmail("tenant", `nichtda-${Date.now()}@example.test`)) === null,
|
|
);
|
|
check(
|
|
"unbekannter Plattform-Admin liefert kein Konto",
|
|
(await findAccountByEmail("platform", `nichtda-${Date.now()}@example.test`)) === null,
|
|
);
|
|
|
|
console.log("10) Aufräumen abgelaufener Tokens");
|
|
const purged = await purgeExpiredTokens();
|
|
check("purge läuft durch", purged >= 0, `${purged} entfernt`);
|
|
|
|
await cleanup();
|
|
await prisma.$disconnect();
|
|
await closeMailProvider();
|
|
await closeQueues();
|
|
|
|
if (failures > 0) {
|
|
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
|
|
process.exit(1);
|
|
}
|
|
console.log("\n✓ SEC2-Self-Service: alle Prüfungen bestanden.");
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error(err);
|
|
await prisma.$disconnect().catch(() => {});
|
|
process.exit(1);
|
|
});
|