DER FEHLER ZUERST, denn er ist meiner von gestern: die Liste der
Meldungsanlaesse stand ZWEIMAL da -- als Standard in meldung.ts und noch einmal
als Weissliste beim Speichern in dash.ts. Beim Einbau des Plattenwaechters habe
ich nur die erste angefasst.
Ergebnis: das Kaestchen "Der Platz auf der Platte wird knapp" liess sich
ankreuzen, der Server warf den Wert beim Speichern weg, und beim naechsten
Laden war es wieder aus. Ohne ein Wort dazu. Nachgemessen am laufenden Dienst:
geschickt ["sicherung","verwaiste","platte"]
gespeichert ["sicherung","verwaiste"]
Die Liste steht jetzt an EINER Stelle (ANLAESSE in meldung.ts) und wird von
beiden benutzt. Gegengeprueft: alle drei kommen an.
SITZUNGEN BEENDEN. Wer sich an einem fremden Rechner angemeldet hat und es
spaeter merkt, hatte keine Moeglichkeit das zurueckzunehmen -- ein geaendertes
Passwort half nicht, die Sitzungen haengen an einer eigenen Tabelle und
ueberleben es. Der Knopf steht im Kontomenue und nur dann, wenn es ueberhaupt
eine zweite Sitzung gibt. Gemessen: 87 offen, 86 beendet, meine lebt.
Dabei in die eigene Falle getreten: der Weg lag zuerst bei den anderen
/auth-Wegen -- und die stehen mit Absicht VOR der Wache, weil man sich anmelden
koennen muss, ohne angemeldet zu sein. Dort ist c.get('user') leer, und der Weg
antwortete mit "Cannot read properties of undefined". Hono setzt Middleware und
Handler in der Reihenfolge ihrer Anmeldung zusammen; der Pfad sagt darueber
nichts.
VERWALTUNGS-VERLAUF. Der Datei-Verlauf beantwortet "was ist mit den Dateien
passiert". Wer den Discord-Webhook geaendert, einen Token angelegt oder ein
Konto entfernt hat, stand nirgends. Bei zwei Konten verschmerzbar -- sobald ein
drittes ueber die Discord-Rolle von selbst entsteht, ist es die erste Frage.
Eigene Tabelle und nicht events: dort traegt jede Zeile path und size, und eine
Einstellungsaenderung muesste path mit etwas fuellen, das kein Pfad ist.
WERTE STEHEN DORT NIE, nur Schluesselnamen. Unter den Einstellungen liegen das
Discord-Geheimnis und das Nextcloud-Passwort; ein Verlauf, der sie mitschreibt,
macht aus einer Tabelle mit einem Passwort eine Tabelle mit allen, die es je
gab.
Beim Einbau derselbe Fehlertyp wie gestern beim SVG: im SQL-Kommentar standen
Backticks, und das Schema liegt in einem Template-Literal -- ein Backtick
beendet es. Der Uebersetzer hat es gefangen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
6.4 KiB
TypeScript
173 lines
6.4 KiB
TypeScript
// 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$<N>$<salt-hex>$<hash-hex>. 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.`)
|
|
}
|