diff --git a/prisma/migrations/20260912082447_api_token/migration.sql b/prisma/migrations/20260912082447_api_token/migration.sql new file mode 100644 index 0000000..e41c224 --- /dev/null +++ b/prisma/migrations/20260912082447_api_token/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "api_token" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "praefix" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "rechte" TEXT NOT NULL, + "gueltigBis" TIMESTAMP(3), + "letzteNutzung" TIMESTAMP(3), + "widerrufenAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_token_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_token_praefix_key" ON "api_token"("praefix"); + +-- CreateIndex +CREATE UNIQUE INDEX "api_token_hash_key" ON "api_token"("hash"); + +-- CreateIndex +CREATE INDEX "api_token_benutzerId_idx" ON "api_token"("benutzerId"); + +-- AddForeignKey +ALTER TABLE "api_token" ADD CONSTRAINT "api_token_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 772bf88..3c64c3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Benutzer { pushGeraete PushGeraet[] meldungen Meldungsnachweis[] telegramChats TelegramChat[] + apiTokens ApiToken[] @@index([aktiv]) @@map("benutzer") @@ -538,3 +539,34 @@ model TelegramKopplung { @@index([gueltigBis]) @@map("telegram_kopplung") } + +/// Zugangstoken fuer die oeffentliche Schnittstelle. +/// +/// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim +/// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert, +/// legt ein neues an, statt das alte irgendwo nachzuschlagen. +model ApiToken { + id String @id @default(cuid()) + benutzerId String + + name String + /// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar. + praefix String @unique + /// SHA-256 des vollstaendigen Tokens. + hash String @unique + + /// Kommagetrennt, siehe src/lib/api.ts. + rechte String + + gueltigBis DateTime? + letzteNutzung DateTime? + /// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll. + widerrufenAm DateTime? + + erstelltAm DateTime @default(now()) + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) + @@map("api_token") +} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..80bd639 --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'vitest' +import { + aenderungEingabe, + abfrage, + aufgabeEingabe, + darf, + rechteLesen, + tokenAusKopfzeilen, + tokenZerlegen, + type Recht, +} from '@/lib/api' + +describe('darf', () => { + test('lässt durch, was ausdrücklich erlaubt ist', () => { + expect(darf(['lesen'], 'lesen')).toBe(true) + }) + + test('Erfassen schließt Lesen nicht ein', () => { + // Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den + // Bestand nicht zu sehen. + expect(darf(['erfassen'], 'lesen')).toBe(false) + }) + + test('Schreiben schließt Erfassen nicht ein', () => { + expect(darf(['schreiben'], 'erfassen')).toBe(false) + }) + + test('mehrere Rechte gelten nebeneinander', () => { + const rechte: Recht[] = ['lesen', 'erfassen'] + expect(darf(rechte, 'lesen')).toBe(true) + expect(darf(rechte, 'erfassen')).toBe(true) + expect(darf(rechte, 'schreiben')).toBe(false) + }) +}) + +describe('rechteLesen', () => { + test('liest die gespeicherte Liste', () => { + expect(rechteLesen('lesen,erfassen')).toEqual(['lesen', 'erfassen']) + }) + + test('verträgt Leerzeichen', () => { + expect(rechteLesen(' lesen , schreiben ')).toEqual(['lesen', 'schreiben']) + }) + + test('wirft Unbekanntes weg, statt daran zu scheitern', () => { + // Ein Recht, das es nicht mehr gibt, darf keine Anfrage abbrechen – + // es soll schlicht nicht gelten. + expect(rechteLesen('lesen,zaubern')).toEqual(['lesen']) + }) + + test('aus leer wird leer', () => { + expect(rechteLesen('')).toEqual([]) + }) +}) + +describe('tokenZerlegen', () => { + const gueltig = `planer_ab12cd34_${'x'.repeat(43)}` + + test('zerlegt einen gültigen Token', () => { + expect(tokenZerlegen(gueltig)).toEqual({ + praefix: 'ab12cd34', + geheimnis: 'x'.repeat(43), + }) + }) + + test('verträgt Leerzeichen drumherum', () => { + expect(tokenZerlegen(` ${gueltig} `)?.praefix).toBe('ab12cd34') + }) + + test('lehnt eine fremde Marke ab', () => { + expect(tokenZerlegen(`fremd_ab12cd34_${'x'.repeat(43)}`)).toBeNull() + }) + + test('lehnt ein zu kurzes Präfix ab', () => { + expect(tokenZerlegen(`planer_ab12_${'x'.repeat(43)}`)).toBeNull() + }) + + test('lehnt ein zu kurzes Geheimnis ab', () => { + expect(tokenZerlegen('planer_ab12cd34_kurz')).toBeNull() + }) + + test('lehnt leeren Text ab', () => { + expect(tokenZerlegen('')).toBeNull() + }) +}) + +describe('tokenAusKopfzeilen', () => { + test('liest Bearer', () => { + expect(tokenAusKopfzeilen('Bearer abc', null)).toBe('abc') + }) + + test('liest Bearer auch klein geschrieben', () => { + expect(tokenAusKopfzeilen('bearer abc', null)).toBe('abc') + }) + + test('liest die eigene Kopfzeile', () => { + expect(tokenAusKopfzeilen(null, 'abc')).toBe('abc') + }) + + test('Bearer geht vor', () => { + expect(tokenAusKopfzeilen('Bearer aus-bearer', 'aus-eigener')).toBe('aus-bearer') + }) + + test('ohne beides null', () => { + expect(tokenAusKopfzeilen(null, null)).toBeNull() + }) + + test('leerer Bearer zählt nicht', () => { + expect(tokenAusKopfzeilen('Bearer ', null)).toBeNull() + }) +}) + +describe('aufgabeEingabe', () => { + test('nimmt das Nötigste an', () => { + const gelesen = aufgabeEingabe.safeParse({ titel: 'Angebot schreiben' }) + expect(gelesen.success).toBe(true) + expect(gelesen.success && gelesen.data.ref).toBeNull() + }) + + test('lehnt einen leeren Titel ab', () => { + expect(aufgabeEingabe.safeParse({ titel: ' ' }).success).toBe(false) + }) + + test('lehnt einen unmöglichen Aufwand ab', () => { + // Mehr als ein Tag am Stück ist keine Aufgabe mehr, sondern ein Projekt. + expect(aufgabeEingabe.safeParse({ titel: 'x', aufwandMinuten: 2000 }).success).toBe( + false, + ) + }) + + test('lehnt eine kaputte Fälligkeit ab', () => { + expect(aufgabeEingabe.safeParse({ titel: 'x', faelligAm: 'morgen' }).success).toBe( + false, + ) + }) + + test('kennt Status, Bereich und Projekt nicht', () => { + const gelesen = aufgabeEingabe.safeParse({ + titel: 'x', + status: 'ERLEDIGT', + bereichId: 'b1', + }) + expect(gelesen.success).toBe(true) + expect(gelesen.success && 'status' in gelesen.data).toBe(false) + expect(gelesen.success && 'bereichId' in gelesen.data).toBe(false) + }) +}) + +describe('aenderungEingabe', () => { + test('nimmt eine einzelne Änderung an', () => { + expect(aenderungEingabe.safeParse({ erledigt: true }).success).toBe(true) + }) + + test('lehnt eine leere Änderung ab', () => { + expect(aenderungEingabe.safeParse({}).success).toBe(false) + }) +}) + +describe('abfrage', () => { + test('setzt Voreinstellungen', () => { + const gelesen = abfrage.safeParse({}) + expect(gelesen.success && gelesen.data).toEqual({ + grenze: 25, + versatz: 0, + offen: null, + }) + }) + + test('deckelt die Grenze', () => { + expect(abfrage.safeParse({ grenze: '5000' }).success).toBe(false) + }) + + test('liest offen als Wahrheitswert', () => { + const gelesen = abfrage.safeParse({ offen: 'true' }) + expect(gelesen.success && gelesen.data.offen).toBe(true) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..89fedc5 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' + +/** + * Regeln der öffentlichen Schnittstelle. + * + * Ein Token ist ein Passwort, das jemand in eine fremde Anwendung einträgt. + * Deshalb gilt hier dieselbe Vorsicht wie bei der Anmeldung: so wenig Rechte + * wie möglich, jede Nutzung nachvollziehbar, und der Klartext existiert genau + * einmal — beim Anlegen. + * + * Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit. + */ + +// --- Rechte ---------------------------------------------------------------- + +export const RECHTE = ['lesen', 'erfassen', 'schreiben'] as const +export type Recht = (typeof RECHTE)[number] + +export const RECHT_NAME: Record = { + lesen: 'Lesen', + erfassen: 'Erfassen', + schreiben: 'Ändern', +} + +export const RECHT_BESCHREIBUNG: Record = { + lesen: 'Aufgaben, Bereiche und Projekte abfragen.', + erfassen: 'Neue Aufgaben anlegen — mehr nicht.', + schreiben: 'Bestehende Aufgaben ändern und abhaken.', +} + +/** + * Erfassen schließt Lesen **nicht** ein. + * + * Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand + * nicht zu sehen. Genau das ist der häufigste Fall — und der, bei dem ein + * mitgelesenes Token am wenigsten anrichtet. + */ +export function darf(rechte: readonly Recht[], gebraucht: Recht): boolean { + return rechte.includes(gebraucht) +} + +export function rechteLesen(gespeichert: string): Recht[] { + return gespeichert + .split(',') + .map((r) => r.trim()) + .filter((r): r is Recht => (RECHTE as readonly string[]).includes(r)) +} + +// --- Aufbau des Tokens ----------------------------------------------------- + +/** + * `planer__` + * + * Das Präfix steht im Klartext in der Datenbank und macht die Zeile + * auffindbar, ohne dass irgendwo der ganze Token liegt. Es taugt zugleich als + * Wiedererkennung in der Liste — „welchen habe ich da eigentlich vergeben?". + * + * Der sichtbare Anfang ist auch der Grund für das Wort „planer" davor: Wer + * einen solchen Wert versehentlich in ein öffentliches Verzeichnis schiebt, + * soll ihn beim Durchsehen erkennen. + */ +export const TOKEN_MARKE = 'planer' +export const PRAEFIX_LAENGE = 8 + +const TOKEN_MUSTER = /^planer_([A-Za-z0-9]{8})_([A-Za-z0-9_-]{32,86})$/ + +export type TokenTeile = { praefix: string; geheimnis: string } + +export function tokenZerlegen(wert: string): TokenTeile | null { + const treffer = TOKEN_MUSTER.exec(wert.trim()) + if (!treffer) return null + const [, praefix, geheimnis] = treffer + if (!praefix || !geheimnis) return null + return { praefix, geheimnis } +} + +/** + * Liest den Token aus der Kopfzeile. + * + * Erlaubt sind `Authorization: Bearer ` und — weil manche Werkzeuge + * nichts anderes können — `X-Planer-Token: `. + */ +export function tokenAusKopfzeilen( + autorisierung: string | null, + eigene: string | null, +): string | null { + if (autorisierung?.toLowerCase().startsWith('bearer ')) { + return autorisierung.slice(7).trim() || null + } + return eigene?.trim() || null +} + +// --- Eingaben -------------------------------------------------------------- + +/** + * Eine Aufgabe, wie sie von außen hereinkommt. + * + * Bewusst schmal: Status, Bereich und Projekt lassen sich über die + * Schnittstelle nicht setzen. Was von außen kommt, landet in der Inbox und + * wird hier einsortiert — sonst räumt ein fremder Dienst die eigene Ordnung um. + */ +export const aufgabeEingabe = z.object({ + titel: z.string().trim().min(1, 'Ohne Titel geht es nicht.').max(200), + notiz: z.string().trim().max(4000).nullable().default(null), + /** + * Kennung beim Aufrufer. Zweimal dieselbe ergibt **eine** Aufgabe. + * + * Das ist der Unterschied zwischen einer Schnittstelle, die man gefahrlos + * wiederholen kann, und einer, die bei jedem Netzhänger Karteileichen + * anlegt. + */ + ref: z.string().trim().min(1).max(64).nullable().default(null), + url: z.url().max(500).nullable().default(null), + faelligAm: z.iso.date().nullable().default(null), + prioritaet: z.coerce.number().int().min(1).max(5).nullable().default(null), + aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().default(null), +}) + +export type AufgabeEingabe = z.infer + +/** Was sich an einer bestehenden Aufgabe ändern lässt. */ +export const aenderungEingabe = z + .object({ + titel: z.string().trim().min(1).max(200).optional(), + notiz: z.string().trim().max(4000).nullable().optional(), + faelligAm: z.iso.date().nullable().optional(), + prioritaet: z.coerce.number().int().min(1).max(5).optional(), + aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().optional(), + erledigt: z.boolean().optional(), + }) + .refine((w) => Object.keys(w).length > 0, 'Es wurde nichts zum Ändern angegeben.') + +export type AenderungEingabe = z.infer + +export const tokenEingabe = z.object({ + name: z.string().trim().min(1, 'Der Token braucht einen Namen.').max(60), + rechte: z.array(z.enum(RECHTE)).min(1, 'Mindestens ein Recht.'), + /** Tage bis zum Ablauf; null heißt unbegrenzt. */ + gueltigTage: z.coerce.number().int().min(1).max(3650).nullable().default(null), +}) + +// --- Antworten ------------------------------------------------------------- + +/** + * Fehlerantworten sind knapp und immer gleich aufgebaut. + * + * Kein Hinweis darauf, ob ein Token unbekannt, abgelaufen oder widerrufen + * ist: Von außen soll sich nicht abfragen lassen, welche Tokens es gibt. + */ +export const FEHLER = { + nichtAngemeldet: { status: 401, fehler: 'Kein gültiger Token.' }, + nichtErlaubt: { status: 403, fehler: 'Dieser Token darf das nicht.' }, + ungueltig: { status: 400, fehler: 'Die Anfrage war fehlerhaft.' }, + nichtGefunden: { status: 404, fehler: 'Gibt es nicht.' }, +} as const + +/** Wie viele Datensätze eine Abfrage höchstens liefert. */ +export const SEITE_MAX = 100 +export const SEITE_STANDARD = 25 + +export const abfrage = z.object({ + grenze: z.coerce.number().int().min(1).max(SEITE_MAX).default(SEITE_STANDARD), + versatz: z.coerce.number().int().min(0).default(0), + offen: z + .enum(['true', 'false']) + .nullable() + .default(null) + .transform((w) => (w === null ? null : w === 'true')), +})