// Anmeldung, Sitzungen und Tokens. // // Ohne Bibliothek, aber nicht selbstgestrickt: scrypt und timingSafeEqual // kommen aus node:crypto. Was hier selbst geschrieben ist, ist die Verdrahtung // — nicht die Kryptografie. import { randomBytes, scryptSync, timingSafeEqual, createHash, } from 'node:crypto' import { db, now, type Token, type User } from './db.js' import { config } from './config.js' const SCRYPT_N = 16384 // ~100 ms je Pruefung, spuerbar fuer einen Angreifer const KEY_LENGTH = 64 /** Format: scrypt$$$. Das Verfahren steht im String, * damit spaetere Parameter alte Passwoerter nicht ungueltig machen. */ export function hashPassword(password: string): string { const salt = randomBytes(16) const key = scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_N }) return `scrypt$${SCRYPT_N}$${salt.toString('hex')}$${key.toString('hex')}` } export function verifyPassword(password: string, stored: string): boolean { const [scheme, n, saltHex, keyHex] = stored.split('$') if (scheme !== 'scrypt' || !n || !saltHex || !keyHex) return false const key = scryptSync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH, { N: Number(n), }) const expected = Buffer.from(keyHex, 'hex') // Laengen muessen vor timingSafeEqual stimmen, sonst wirft es. if (key.length !== expected.length) return false return timingSafeEqual(key, expected) } // ---------------------------------------------------------------- Sitzungen export const SESSION_COOKIE = 'sid' export function createSession(userId: number): { id: string; expiresAt: number } { const id = randomBytes(32).toString('base64url') const expiresAt = now() + config.sessionDays * 86_400_000 db.prepare( 'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)', ).run(id, userId, now(), expiresAt) return { id, expiresAt } } /** Wann jemand zuletzt da war -- hoechstens einmal je Stunde geschrieben. * * Bei jedem Aufruf zu schreiben waere ein UPDATE je Anfrage, und das Dashboard * stellt beim Blaettern durch 3600 Bilder eine Menge davon. Auf die Stunde * genau reicht fuer die Frage "benutzt das noch jemand" vollauf. */ const STUNDE = 3600_000 export function userForSession(sessionId: string | undefined): User | null { if (!sessionId) return null const row = db .prepare( `SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?`, ) .get(sessionId, now()) as User | undefined if (!row) return null const jetzt = now() if (!row.zuletzt_da || jetzt - row.zuletzt_da > STUNDE) { db.prepare('UPDATE users SET zuletzt_da = ? WHERE id = ?').run(jetzt, row.id) } return row } /** Alle Sitzungen eines Benutzers beenden -- ausser der laufenden. * * WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt, * hatte bisher keine Moeglichkeit, das zurueckzunehmen. Das Passwort zu * aendern half nicht -- die Sitzungen haengen an einer eigenen Tabelle und * ueberleben es. * * Die EIGENE bleibt stehen: wer auf den Knopf drueckt, will sich nicht selbst * aussperren. */ export function destroyOtherSessions(userId: number, behalten: string): number { const r = db .prepare('DELETE FROM sessions WHERE user_id = ? AND id <> ?') .run(userId, behalten) return r.changes } /** Wie viele Sitzungen gerade offen sind. Abgelaufene zaehlen nicht mit -- * sie sind nur noch nicht weggeraeumt. */ export function countSessions(userId: number): number { return ( db .prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?') .get(userId, now()) as { n: number } ).n } export function destroySession(sessionId: string) { db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId) } // ------------------------------------------------------------------- Tokens /** Der Klartext-Token. Traegt ein Praefix, damit er in einem Log oder einer * Konfigurationsdatei sofort als solcher zu erkennen ist. */ export function generateToken(): string { return `d4rk_${randomBytes(24).toString('base64url')}` } export function tokenHash(token: string): string { return createHash('sha256').update(token).digest('hex') } /** Den Token zu einem Authorization-Kopf finden. * * Gesucht wird ueber den HASH, nicht ueber den Klartext — in der Datenbank * steht nichts, womit jemand hochladen koennte, der sie in die Haende * bekommt. */ export function tokenFromHeader(header: string | undefined): Token | null { if (!header) return null // "Bearer xyz" ODER nur "xyz" -- und das zweite ist nicht Bequemlichkeit, // sondern Vertraeglichkeit: Fivemanage nimmt den Schluessel NACKT im // Authorization-Kopf entgegen, und genau so steht er in jedem FiveM-Skript, // das dorthin hochlaedt (nachgesehen im SDK und in Devm-Camera). Wer von // dort umzieht, soll nur die Adresse tauschen muessen. // // Ein Risiko ist das keines: geprueft wird ohnehin der Hash gegen die // Tabelle. Ein Kopf, der zu keinem Token gehoert, faellt so oder so durch. const roh = header.trim() const wert = /^Bearer\s+(.+)$/i.exec(roh)?.[1] ?? roh if (!wert) return null const row = db .prepare('SELECT * FROM tokens WHERE hash = ?') .get(tokenHash(wert)) as Token | undefined if (!row) return null db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id) return row } /** Darf dieser Token auf diesen Pfad schreiben? */ export function tokenAllows(token: Token, path: string): boolean { if (!token.prefix) return true const prefix = token.prefix.replace(/\/+$/, '') + '/' return path.startsWith(prefix) } // -------------------------------------------------------------------- Start /** Den ersten Benutzer anlegen, falls noch keiner da ist. * * Nur DANN — ein geaendertes ADMIN_PASSWORD setzt ein bestehendes Passwort * nicht zurueck. Wer sich ausgesperrt hat, loescht die Zeile in der Datenbank * und startet neu. */ export function ensureAdmin() { const count = db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } if (count.n > 0) return db.prepare( 'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', ).run(config.adminUsername, hashPassword(config.adminPassword), now()) console.log(`[auth] Benutzer "${config.adminUsername}" angelegt.`) }