Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
randomBytes,
|
||||
scrypt,
|
||||
timingSafeEqual,
|
||||
type ScryptOptions,
|
||||
} from "node:crypto";
|
||||
|
||||
// promisify() greift bei scrypt die Überladung ohne Optionen ab — deshalb
|
||||
// hier von Hand eingepackt.
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
keyLength: number,
|
||||
options: ScryptOptions,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, keyLength, options, (error, derived) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derived);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwörter mit scrypt aus der Node-Standardbibliothek.
|
||||
*
|
||||
* Warum nicht Argon2id, das heute meist zuerst empfohlen wird: dafür bräuchte
|
||||
* es ein natives Modul, das zur Plattform des Containers passen muss. scrypt
|
||||
* ist ebenfalls speicherhart, gilt als solide und kostet null Abhängigkeiten
|
||||
* und null Build-Risiko — bei einer selbst gehosteten Anwendung wiegt das
|
||||
* schwerer als der Unterschied zwischen zwei guten Verfahren.
|
||||
*
|
||||
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
|
||||
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
|
||||
* entwerten.
|
||||
*/
|
||||
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(PARAMS.saltLength);
|
||||
const derived = await scryptAsync(
|
||||
password.normalize("NFKC"),
|
||||
salt,
|
||||
PARAMS.keyLength,
|
||||
{
|
||||
N: PARAMS.N,
|
||||
r: PARAMS.r,
|
||||
p: PARAMS.p,
|
||||
// Der Standardwert reicht für N = 32768 nicht aus.
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
"scrypt",
|
||||
PARAMS.N,
|
||||
PARAMS.r,
|
||||
PARAMS.p,
|
||||
salt.toString("base64"),
|
||||
derived.toString("base64"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(parts[4], "base64");
|
||||
expected = Buffer.from(parts[5], "base64");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scryptAsync(password.normalize("NFKC"), salt, expected.length, {
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vergleich in konstanter Zeit — ein früher Abbruch würde verraten, wie
|
||||
// viele Bytes bereits stimmen.
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/** Mindestanforderungen an ein Passwort. */
|
||||
export const MIN_PASSWORD_LENGTH = 10;
|
||||
|
||||
export function describePasswordRules(): string {
|
||||
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
|
||||
}
|
||||
Reference in New Issue
Block a user