Zehn Befunde, alle behoben. Die wichtigsten: scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17. Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden still erneuert — das ist der einzige Moment, in dem das Klartextpasswort vorliegt. Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128 Zeichen, geprueft vor dem Hashen. Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich, dass das allein nicht genuegt — besonders beim Upload, der multipart entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains). Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options, nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg. Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS neben SESSION_IDLE_DAYS, beide einstellbar. Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde Konten bei der Anmeldung, eine Bremse je Absender gegen breites Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen werden aufgeraeumt. Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst. Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
249 lines
8.8 KiB
Plaintext
249 lines
8.8 KiB
Plaintext
// QR-Code-Generator — Datenmodell
|
|
// Doku: https://pris.ly/d/prisma-schema
|
|
|
|
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
|
///
|
|
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
|
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
|
/// erste Gruppe erstellt.
|
|
enum Role {
|
|
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
|
ADMIN
|
|
/// Darf Codes anlegen, ändern und löschen.
|
|
EDITOR
|
|
/// Darf nur lesen und herunterladen.
|
|
VIEWER
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
|
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
|
|
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
|
|
passwordHash String?
|
|
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
|
|
/// sich das Konto nur mit Passwort anmeldet.
|
|
oidcSubject String? @unique
|
|
/// Darf Gruppen, Benutzer und Domains verwalten.
|
|
isInstanceAdmin Boolean @default(false)
|
|
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
|
/// werden beim nächsten Zugriff verworfen.
|
|
isActive Boolean @default(true)
|
|
failedLogins Int @default(0)
|
|
lockedUntil DateTime?
|
|
lastLoginAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
sessions Session[]
|
|
memberships Membership[]
|
|
createdCodes QrCode[] @relation("QrCodeCreator")
|
|
|
|
@@index([email])
|
|
}
|
|
|
|
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
|
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
|
/// deaktiviert wird.
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
|
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
|
tokenHash String @unique
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
|
|
expiresAt DateTime
|
|
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
|
|
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
|
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
|
|
lastSeenAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
@@index([absoluteExpiresAt])
|
|
}
|
|
|
|
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
|
model Group {
|
|
id String @id @default(cuid())
|
|
name String @unique
|
|
description String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships Membership[]
|
|
codes QrCode[]
|
|
domainGrants DomainGrant[]
|
|
}
|
|
|
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
|
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
|
model Membership {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
groupId String
|
|
role Role
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
|
|
///
|
|
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
|
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
|
|
/// damit lässt sich über einen gefälschten Header keine fremde Domain
|
|
/// ansprechen.
|
|
model Domain {
|
|
id String @id @default(cuid())
|
|
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
|
|
hostname String @unique
|
|
label String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
grants DomainGrant[]
|
|
codes QrCode[]
|
|
}
|
|
|
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
|
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
|
|
/// `isDefault`.
|
|
model DomainGrant {
|
|
id String @id @default(cuid())
|
|
domainId String
|
|
groupId String
|
|
isDefault Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([domainId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
|
///
|
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
|
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
|
|
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
|
|
/// automatisch sichtbar.
|
|
model QrTypeSetting {
|
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
|
type String @id
|
|
enabled Boolean
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
|
///
|
|
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
|
model QrCode {
|
|
id String @id @default(cuid())
|
|
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
|
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
|
slug String
|
|
type String
|
|
label String?
|
|
data Json
|
|
fgColor String @default("#000000")
|
|
bgColor String @default("#FFFFFF")
|
|
size Int @default(512)
|
|
ecLevel String @default("M")
|
|
margin Int @default(4)
|
|
scans Int @default(0)
|
|
lastScanAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
|
|
domainId String
|
|
domain Domain @relation(fields: [domainId], references: [id])
|
|
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
|
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
|
groupId String
|
|
group Group @relation(fields: [groupId], references: [id])
|
|
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
|
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
|
createdById String?
|
|
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
|
|
/// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit.
|
|
uploads Upload[]
|
|
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
|
feedback Feedback[]
|
|
|
|
@@unique([domainId, slug])
|
|
@@index([createdAt])
|
|
@@index([groupId])
|
|
@@index([domainId])
|
|
}
|
|
|
|
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
|
/// einem Bild-Code.
|
|
///
|
|
/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es
|
|
/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
|
/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer
|
|
/// Installation dieser Größenordnung ist das der bessere Tausch als ein
|
|
/// zweites Volumen, das beim Sichern vergessen werden kann.
|
|
model Upload {
|
|
id String @id @default(cuid())
|
|
/// Ursprünglicher Dateiname, für den Download.
|
|
filename String
|
|
mimeType String
|
|
/// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden.
|
|
size Int
|
|
data Bytes
|
|
createdAt DateTime @default(now())
|
|
|
|
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
|
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
|
/// `src/lib/uploads.ts` nach einer Weile weg.
|
|
qrCodeId String?
|
|
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([qrCodeId])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
|
/// abgegeben hat.
|
|
///
|
|
/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen
|
|
/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt,
|
|
/// soll nicht wiedererkennbar sein.
|
|
model Feedback {
|
|
id String @id @default(cuid())
|
|
qrCodeId String
|
|
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
|
/// 1 bis 5.
|
|
rating Int
|
|
comment String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([qrCodeId, createdAt])
|
|
}
|