diff --git a/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts index 94c1253..a107e44 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts @@ -804,3 +804,156 @@ describe('Anmeldename = Basic-Benutzer (E-Mail-Anmeldung, gemessen gegen Nextclo }); }); }); + +describe('Ausstehende Widerrufe (WR-02)', () => { + function clocked(s: ReturnType) { + const clock = { t: 1_000_000 }; + s.gate.now = () => clock.t; + s.service.now = () => clock.t; + return clock; + } + + it('429 auf cloud/user direkt nach getapppassword: der Widerruf wartet das Ende der Sperre ab und geht dann raus', async () => { + let paused = true; + const s = setup({ + handler: (req) => { + if (req.url.endsWith('/core/getapppassword')) + return reply(200, ocs({ apppassword: 'app-pw-123' })); + if (req.url.endsWith('/cloud/user') && paused) + return reply(429, '', { 'retry-after': '120' }); + return happy(req); + }, + }); + const clock = clocked(s); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim')); + expect(err?.body.code).toBe('nextcloudLocked'); + // Waehrend der Sperre geht kein Widerruf raus, er wartet in der Warteschlange. + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0); + expect(s.service.pendingRevokeCount).toBe(1); + + await s.service.retryPendingRevokes(); + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0); + + paused = false; + clock.t += 122_000; + await s.service.retryPendingRevokes(); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].headers.authorization).toBe(AUTH_APP); + expect(s.service.pendingRevokeCount).toBe(0); + s.service.onModuleDestroy(); + }); + + it('Netzfehler beim Widerruf des alten App-Passworts: neuer Versuch nach einer Minute', async () => { + let down = true; + const s = setup({ + rows: [makeRow({ encryptedAppPassword: 'enc(old-pw)' })], + handler: (req) => { + if (req.method === 'DELETE' && down) { + throw Object.assign(new Error('x'), { code: 'ECONNRESET' }); + } + return happy(req); + }, + }); + const clock = clocked(s); + await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'); + expect(s.rows[0].encryptedAppPassword).toBe('enc(app-pw-123)'); + expect(s.service.pendingRevokeCount).toBe(1); + down = false; + clock.t += 61_000; + await s.service.retryPendingRevokes(); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(2); + expect(deletes[1].headers.authorization).toBe(AUTH_OLD); + expect(s.service.pendingRevokeCount).toBe(0); + s.service.onModuleDestroy(); + }); + + it('401 oder 403 auf den Widerruf: nicht erneut versuchen (Zugang gilt ohnehin nicht)', async () => { + const s = setup({ rows: [makeRow()], handler: () => reply(401) }); + await s.service.disconnect('t1', 'u1'); + expect(s.service.pendingRevokeCount).toBe(0); + }); + + it('nach sechs gescheiterten Versuchen wird aufgegeben', async () => { + const s = setup({ rows: [makeRow()], handler: () => reply(503) }); + const clock = clocked(s); + await s.service.disconnect('t1', 'u1'); + expect(s.service.pendingRevokeCount).toBe(1); + for (let i = 0; i < 10; i++) { + clock.t += 61_000; + await s.service.retryPendingRevokes(); + } + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(6); + expect(s.service.pendingRevokeCount).toBe(0); + s.service.onModuleDestroy(); + }); +}); + +describe('Abgebrochene Browser-Anmeldung (IN-04)', () => { + function flowSetup(grantAfterCancel: () => boolean) { + return setup({ + handler: (req) => { + if (req.url === 'http://cloud.example/index.php/login/v2') { + return reply(200, { + poll: { token: TOKEN128, endpoint: 'http://evil.example/poll' }, + login: `http://evil.example/login/v2/flow/${TOKEN128}`, + }); + } + if (req.url.endsWith('/login/v2/poll')) { + return grantAfterCancel() + ? reply(200, { server: 'x', loginName: 'anna', appPassword: 'app-pw-123' }) + : reply(404); + } + return happy(req); + }, + }); + } + + it('wird die Anmeldung nach "Abbrechen" doch noch bestaetigt, widerruft der Server das Passwort sofort', async () => { + let granted = false; + const s = flowSetup(() => granted); + const { flowId } = await s.service.startFlow('t1', 'u1'); + await s.service.cancelFlow('t1', 'u1', flowId); + expect((await errOf(s.service.pollFlow('t1', 'u1', flowId)))?.status).toBe(404); + + await s.service.sweepCancelledFlows(); + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0); + + granted = true; + s.flows.now = () => Date.now() + 11_000; + await s.service.sweepCancelledFlows(); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].headers.authorization).toBe(AUTH_APP); + expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false); + // danach wird der Ablauf nicht mehr abgefragt + s.flows.now = () => Date.now() + 30_000; + const polls = s.calls.length; + await s.service.sweepCancelledFlows(); + expect(s.calls.length).toBe(polls); + s.service.onModuleDestroy(); + }); + + it('ein neuer Start ersetzt den alten Ablauf, der alte wird aber weiter beobachtet', async () => { + const s = flowSetup(() => true); + const first = await s.service.startFlow('t1', 'u1'); + await s.service.startFlow('t1', 'u1'); + expect(s.flows.get(first.flowId, 't1', 'u1')).toBeUndefined(); + await s.service.sweepCancelledFlows(); + // Der Start selbst ist keine Abfrage: der ersetzte Ablauf wird abgefragt und widerrufen. + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(1); + s.service.onModuleDestroy(); + }); + + it('abgelaufene abgebrochene Ablaeufe werden nicht mehr abgefragt', async () => { + const s = flowSetup(() => true); + const { flowId } = await s.service.startFlow('t1', 'u1'); + await s.service.cancelFlow('t1', 'u1', flowId); + s.flows.now = () => Date.now() + 21 * 60 * 1000; + const before = s.calls.length; + await s.service.sweepCancelledFlows(); + expect(s.calls.length).toBe(before); + s.service.onModuleDestroy(); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts index e782899..93a924f 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { HttpException, Inject, Injectable, Logger } from '@nestjs/common'; +import { HttpException, Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; @@ -44,6 +44,30 @@ export type FlowPollResult = | { state: 'connected' } | { state: 'failed'; code: string; message: string }; +/** Hoechstzahl ausstehender Widerrufe im Arbeitsspeicher (WR-02); der aelteste fliegt zuerst raus. */ +export const PENDING_REVOKE_MAX = 200; +/** So oft wird ein Widerruf hoechstens versucht, bevor er aufgegeben wird. */ +export const PENDING_REVOKE_MAX_ATTEMPTS = 6; +/** Wartezeit vor einem neuen Versuch nach Netz- oder Serverfehlern. */ +export const PENDING_REVOKE_RETRY_MS = 60_000; +/** Abstand der Abfragen abgebrochener Browser-Anmeldungen (IN-04). */ +export const CANCELLED_FLOW_POLL_MS = 10_000; + +/** + * Ein App-Passwort, dessen Widerruf noch aussteht (WR-02). Liegt NUR im Arbeitsspeicher, + * wird nie geloggt und nie an eine andere Adresse als `baseUrl` geschickt. + */ +interface PendingRevoke { + baseUrl: string; + loginName: string; + appPassword: string; + credentialKey?: string; + attempts: number; + notBefore: number; +} + +type RevokeOutcome = { done: true } | { done: false; retryAfterMs: number }; + /** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert: Zugangsschluessel der Aufrufsperre. */ export function credentialKeyOf(encryptedAppPassword: string): string { return createHash('sha256').update(encryptedAppPassword).digest('hex').slice(0, 16); @@ -66,12 +90,28 @@ export function credentialKeyOf(encryptedAppPassword: string): string { * gespeichert werden konnte, wird sofort widerrufen; beim erneuten Verbinden * wird das alte (gleiche Adresse) zuerst widerrufen; ein Zugang fuer eine * andere Adresse wird nie an diese gesendet. + * + * Scheitert ein Widerruf voruebergehend (Aufrufsperre nach einem 429, Netz, + * Zeitueberschreitung, Wartung, 5xx), kommt er in eine kleine Warteschlange im + * Arbeitsspeicher und wird nach dem Ende der Sperre bzw. nach einer Minute + * erneut versucht (WR-02; hoechstens 200 Eintraege, hoechstens 6 Versuche, ein + * Neustart verliert sie). Abgebrochene Browser-Anmeldungen werden bis zu ihrem + * Ablauf weiter abgefragt; wird dort doch noch ein App-Passwort ausgestellt, + * wird es sofort widerrufen (IN-04). */ @Injectable() -export class NextcloudFilesAccountService { +export class NextcloudFilesAccountService implements OnModuleDestroy { private readonly logger = new Logger(NextcloudFilesAccountService.name); /** Ende der Warteschlange je Mandant und Benutzer (siehe `withUserLock`). */ private readonly userLocks = new Map>(); + /** Ausstehende Widerrufe (WR-02). */ + private readonly pendingRevokes: PendingRevoke[] = []; + private revokeTimer: NodeJS.Timeout | undefined; + private flowSweepTimer: NodeJS.Timeout | undefined; + private flowSweepRunning = false; + + /** Zeitquelle in Millisekunden; Tests ersetzen sie. */ + now: () => number = () => Date.now(); constructor( private readonly prisma: PrismaService, @@ -82,8 +122,19 @@ export class NextcloudFilesAccountService { private readonly gate: NextcloudCallGate, @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, ) { - // Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr. - this.settings.onAddressChange((tenantId) => this.flows.clearTenant(tenantId)); + // Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr (sie werden an + // ihrer alten Adresse noch beobachtet, siehe `sweepCancelledFlows`). + this.settings.onAddressChange((tenantId) => { + this.flows.clearTenant(tenantId); + this.scheduleFlowSweep(); + }); + } + + onModuleDestroy(): void { + clearTimeout(this.revokeTimer); + clearTimeout(this.flowSweepTimer); + this.revokeTimer = undefined; + this.flowSweepTimer = undefined; } // --- Zeilenzugriff (jeweils eigener, an Mandant UND Benutzer gebundener Klient) ---------- @@ -251,6 +302,8 @@ export class NextcloudFilesAccountService { const started = await startLoginFlow(this.transport, this.gate, baseUrl); if (!started.ok) throw authFailureToException(started); const entry = this.flows.create(tenantId, userId, baseUrl, started.pollToken); + // Ein ersetzter frueherer Ablauf desselben Benutzers wird weiter beobachtet (IN-04). + this.scheduleFlowSweep(); return { flowId: entry.flowId, loginUrl: started.loginUrl, @@ -270,7 +323,8 @@ export class NextcloudFilesAccountService { // Die Adresse darf sich seit dem Start nicht geaendert haben. const current = await this.settings.getBaseUrl(tenantId); if (current !== entry.baseUrl) { - this.flows.remove(flowId); + this.flows.cancel(flowId); + this.scheduleFlowSweep(); throw ncErrorDefault('flowExpired'); } @@ -324,10 +378,54 @@ export class NextcloudFilesAccountService { async cancelFlow(tenantId: string, userId: string, flowId: string): Promise<{ cancelled: true }> { const found = this.flows.lookup(flowId, tenantId, userId); if (found.state === 'missing') throw ncErrorDefault('notFound'); - this.flows.remove(flowId); + if (found.state === 'expired') { + this.flows.remove(flowId); + } else { + // Nicht vergessen, sondern bis zum Ablauf weiter abfragen (IN-04). + this.flows.cancel(flowId); + this.scheduleFlowSweep(); + } return { cancelled: true }; } + /** + * Fragt abgebrochene Browser-Anmeldungen ab (IN-04): hoechstens alle 10 s je Ablauf, bis er + * abgelaufen ist. Bestaetigt der Benutzer die Anmeldung doch noch, wird das ausgestellte + * App-Passwort sofort widerrufen und nirgends gespeichert. Der Login Flow zaehlt bei + * Nextcloud nicht als Fehlanmeldung; eine Aufrufsperre wird respektiert (dann spaeter). + */ + async sweepCancelledFlows(): Promise { + if (this.flowSweepRunning) return; + this.flowSweepRunning = true; + try { + for (const entry of this.flows.cancelledDue(CANCELLED_FLOW_POLL_MS)) { + this.flows.markPolled(entry); + let polled: Awaited>; + try { + polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken); + } catch { + continue; + } + if (polled.ok && polled.state === 'granted') { + this.flows.remove(entry.flowId); + await this.revokeFresh(entry.baseUrl, polled.loginName, polled.appPassword); + } + } + } finally { + this.flowSweepRunning = false; + } + this.scheduleFlowSweep(); + } + + private scheduleFlowSweep(): void { + if (this.flowSweepTimer || !this.flows.hasCancelled()) return; + this.flowSweepTimer = setTimeout(() => { + this.flowSweepTimer = undefined; + void this.sweepCancelledFlows().catch(() => undefined); + }, CANCELLED_FLOW_POLL_MS); + this.flowSweepTimer.unref?.(); + } + private failed(err: unknown): FlowPollResult { if (err instanceof HttpException) { const body = err.getResponse() as { code?: string; message?: string }; @@ -471,14 +569,38 @@ export class NextcloudFilesAccountService { await this.revokeBestEffort(baseUrl, loginName, appPassword); } + /** + * Widerruf "so gut es geht" (nie ein Fehler nach aussen). Scheitert er voruebergehend, + * kommt er in die Warteschlange (WR-02) und wird spaeter erneut versucht. + */ private async revokeBestEffort( baseUrl: string, loginName: string, appPassword: string, credentialKey?: string, ): Promise { + const outcome = await this.tryRevoke(baseUrl, loginName, appPassword, credentialKey); + if (outcome.done) return; + this.queueRevoke({ + baseUrl, + loginName, + appPassword, + credentialKey, + attempts: 1, + notBefore: this.now() + outcome.retryAfterMs, + }); + } + + /** Ein Widerrufsversuch. `done: false` heisst: voruebergehend gescheitert, spaeter erneut. */ + private async tryRevoke( + baseUrl: string, + loginName: string, + appPassword: string, + credentialKey?: string, + ): Promise { + let res: Awaited>; try { - const res = await revokeAppPassword( + res = await revokeAppPassword( this.transport, this.gate, baseUrl, @@ -486,12 +608,86 @@ export class NextcloudFilesAccountService { appPassword, credentialKey, ); - if (!res.ok) { - this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`); - } } catch { - this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen'); + this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen; neuer Versuch folgt'); + return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; } + if (res.ok) return { done: true }; + switch (res.kind) { + case 'locked': + // Aufrufsperre (429): erst nach ihrem Ende erneut, mit einer Sekunde Abstand. + return { done: false, retryAfterMs: ((res.retryAfterSeconds ?? 900) + 1) * 1000 }; + case 'network': + case 'timeout': + case 'maintenance': + this.logger.warn( + `Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)}); neuer Versuch folgt`, + ); + return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; + case 'upstream': + if ((res.status ?? 0) >= 500) { + this.logger.warn( + `Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)}); neuer Versuch folgt`, + ); + return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; + } + break; + default: + break; + } + // 401/403/tot: der Zugang gilt ohnehin nicht (mehr); sonst endgueltig nicht widerrufbar. + this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`); + return { done: true }; + } + + private queueRevoke(item: PendingRevoke): void { + if (this.pendingRevokes.length >= PENDING_REVOKE_MAX) { + this.pendingRevokes.shift(); + this.logger.warn('Zu viele ausstehende Widerrufe; der älteste wird verworfen'); + } + this.pendingRevokes.push(item); + this.scheduleRevokes(); + } + + private scheduleRevokes(): void { + if (this.revokeTimer || this.pendingRevokes.length === 0) return; + const next = Math.min(...this.pendingRevokes.map((p) => p.notBefore)); + const delay = Math.max(1000, next - this.now()); + this.revokeTimer = setTimeout(() => { + this.revokeTimer = undefined; + void this.retryPendingRevokes().catch(() => undefined); + }, delay); + this.revokeTimer.unref?.(); + } + + /** Zahl der ausstehenden Widerrufe (fuer Tests und Betrieb, nie die Werte selbst). */ + get pendingRevokeCount(): number { + return this.pendingRevokes.length; + } + + /** Arbeitet die faelligen ausstehenden Widerrufe ab; die uebrigen bleiben liegen. */ + async retryPendingRevokes(): Promise { + const now = this.now(); + const due = this.pendingRevokes.filter((p) => p.notBefore <= now); + for (const item of due) this.pendingRevokes.splice(this.pendingRevokes.indexOf(item), 1); + for (const item of due) { + const outcome = await this.tryRevoke( + item.baseUrl, + item.loginName, + item.appPassword, + item.credentialKey, + ); + if (outcome.done) continue; + item.attempts += 1; + if (item.attempts >= PENDING_REVOKE_MAX_ATTEMPTS) { + this.logger.warn('Widerruf eines App-Passworts nach mehreren Versuchen aufgegeben'); + continue; + } + item.notBefore = this.now() + outcome.retryAfterMs; + if (this.pendingRevokes.length >= PENDING_REVOKE_MAX) this.pendingRevokes.shift(); + this.pendingRevokes.push(item); + } + this.scheduleRevokes(); } } diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts index 02409da..610acfb 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts @@ -168,6 +168,27 @@ describe('LoginFlowStore', () => { expect(store.get(b.flowId, 't1', 'u1')?.pollToken).toBe('tok-b'); }); + it('abgebrochene Ablaeufe sind fuer den Benutzer weg, werden aber bis zum Ablauf beobachtet (IN-04)', () => { + const { store, clock } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok-a'); + store.cancel(a.flowId); + expect(store.lookup(a.flowId, 't1', 'u1')).toEqual({ state: 'missing' }); + expect(store.hasCancelled()).toBe(true); + expect(store.cancelledDue(10_000).map((e) => e.pollToken)).toEqual(['tok-a']); + clock.t += FLOW_TTL_MS; + expect(store.cancelledDue(10_000)).toEqual([]); + expect(store.hasCancelled()).toBe(false); + }); + + it('abgebrochene Ablaeufe zaehlen nicht gegen die 200 offenen', () => { + const { store } = makeStore(); + for (let i = 0; i < FLOW_MAX_TOTAL; i++) { + const e = store.create('t1', `u${i}`, 'http://c.example', `tok-${i}`); + store.cancel(e.flowId); + } + expect(() => store.create('t1', 'neu', 'http://c.example', 'tok')).not.toThrow(); + }); + it('fremder Benutzer oder Mandant: nichts (wie unbekannt)', () => { const { store } = makeStore(); const a = store.create('t1', 'u1', 'http://c.example', 'tok'); diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts index 7de56ef..a371303 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts @@ -176,6 +176,8 @@ export const FLOW_MAX_TOTAL = 200; export const FLOW_POLL_MIN_INTERVAL_MS = 1500; /** Abgelaufene Eintraege bleiben noch kurz, damit die Abfrage 410 statt 404 melden kann. */ const FLOW_TOMBSTONE_MS = 5 * 60 * 1000; +/** Obergrenze fuer abgebrochene, noch beobachtete Ablaeufe (aelteste fliegen zuerst raus). */ +export const FLOW_MAX_CANCELLED = 500; export interface FlowEntry { flowId: string; @@ -187,6 +189,12 @@ export interface FlowEntry { pollToken: string; expiresAt: number; lastPollAt: number; + /** + * Abgebrochen (vom Benutzer, durch einen neuen Start oder einen Adresswechsel). Fuer den + * Benutzer ist der Ablauf weg; der Server fragt ihn aber bis zum Ablauf weiter ab und + * widerruft ein doch noch ausgestelltes App-Passwort sofort (IN-04). + */ + cancelled?: boolean; } export type FlowLookup = @@ -200,6 +208,11 @@ export type FlowLookup = * 20 Minuten Lebensdauer. Ein Neustart der API verliert offene Abläufe — der * Benutzer startet dann einfach neu. Fremde Kennungen (anderer Benutzer oder * Mandant) sind nicht von unbekannten zu unterscheiden. + * + * Abgebrochene Ablaeufe bleiben als "abgebrochen" bis zu ihrem Ablauf stehen + * (IN-04): bestaetigt der Benutzer die Anmeldung in der Nextcloud trotzdem noch, + * stellt Nextcloud ein App-Passwort aus, das sonst nie abgeholt und nie + * widerrufen wuerde. Sie zaehlen nicht gegen die 200 offenen Ablaeufe. */ @Injectable() export class LoginFlowStore { @@ -217,15 +230,33 @@ export class LoginFlowStore { private liveCount(): number { const now = this.now(); let n = 0; - for (const entry of this.flows.values()) if (now < entry.expiresAt) n += 1; + for (const entry of this.flows.values()) { + if (!entry.cancelled && now < entry.expiresAt) n += 1; + } return n; } + private markCancelled(entry: FlowEntry): void { + entry.cancelled = true; + let cancelled = 0; + for (const e of this.flows.values()) if (e.cancelled) cancelled += 1; + // Map behaelt die Einfuegereihenfolge: die aeltesten abgebrochenen zuerst verwerfen. + for (const [id, e] of this.flows) { + if (cancelled <= FLOW_MAX_CANCELLED) break; + if (e.cancelled) { + this.flows.delete(id); + cancelled -= 1; + } + } + } + create(tenantId: string, userId: string, baseUrl: string, pollToken: string): FlowEntry { this.prune(); - // Ein neuer Start ersetzt den alten desselben Benutzers. - for (const [id, entry] of this.flows) { - if (entry.tenantId === tenantId && entry.userId === userId) this.flows.delete(id); + // Ein neuer Start ersetzt den alten desselben Benutzers (der alte wird weiter beobachtet). + for (const entry of this.flows.values()) { + if (entry.tenantId === tenantId && entry.userId === userId && !entry.cancelled) { + this.markCancelled(entry); + } } if (this.liveCount() >= FLOW_MAX_TOTAL) throw ncErrorDefault('tooManyFlows'); const now = this.now(); @@ -245,7 +276,7 @@ export class LoginFlowStore { lookup(flowId: string, tenantId: string, userId: string): FlowLookup { this.prune(); const entry = this.flows.get(flowId); - if (!entry || entry.tenantId !== tenantId || entry.userId !== userId) { + if (!entry || entry.cancelled || entry.tenantId !== tenantId || entry.userId !== userId) { return { state: 'missing' }; } if (this.now() >= entry.expiresAt) return { state: 'expired' }; @@ -262,6 +293,34 @@ export class LoginFlowStore { this.flows.delete(flowId); } + /** Fuer den Benutzer abbrechen, aber bis zum Ablauf weiter beobachten (IN-04). */ + cancel(flowId: string): void { + const entry = this.flows.get(flowId); + if (entry && !entry.cancelled) this.markCancelled(entry); + } + + /** Abgebrochene, noch nicht abgelaufene Ablaeufe, deren letzte Abfrage `minIntervalMs` her ist. */ + cancelledDue(minIntervalMs: number): FlowEntry[] { + this.prune(); + const now = this.now(); + const due: FlowEntry[] = []; + for (const entry of this.flows.values()) { + if (!entry.cancelled) continue; + if (now >= entry.expiresAt) { + this.flows.delete(entry.flowId); + continue; + } + if (now - entry.lastPollAt >= minIntervalMs) due.push(entry); + } + return due; + } + + /** Gibt es abgebrochene Ablaeufe, die noch beobachtet werden muessen? */ + hasCancelled(): boolean { + for (const entry of this.flows.values()) if (entry.cancelled) return true; + return false; + } + /** Hoechstens eine Nextcloud-Abfrage je 1,5 s und Ablauf; schnellere Browser-Abfragen bleiben `pending`. */ shouldPoll(entry: FlowEntry): boolean { return this.now() - entry.lastPollAt >= FLOW_POLL_MIN_INTERVAL_MS; @@ -271,10 +330,14 @@ export class LoginFlowStore { entry.lastPollAt = this.now(); } - /** Nach einem Adresswechsel: alle Abläufe der Organisation verwerfen. */ + /** + * Nach einem Adresswechsel: alle Abläufe der Organisation fuer die Benutzer verwerfen. Sie + * werden an IHRER (alten) Adresse weiter beobachtet, damit ein dort doch noch ausgestelltes + * App-Passwort widerrufen wird — nie wird ein Zugang an eine andere Adresse geschickt. + */ clearTenant(tenantId: string): void { - for (const [id, entry] of this.flows) { - if (entry.tenantId === tenantId) this.flows.delete(id); + for (const entry of this.flows.values()) { + if (entry.tenantId === tenantId && !entry.cancelled) this.markCancelled(entry); } } } diff --git a/docs/anleitung-betrieb.md b/docs/anleitung-betrieb.md index 15b2a24..42deec6 100644 --- a/docs/anleitung-betrieb.md +++ b/docs/anleitung-betrieb.md @@ -191,7 +191,7 @@ Das Modul „Dateien“ spricht aus dem `api`-Container mit der Nextcloud der In - **Zertifikate:** Das Zertifikat der Nextcloud wird immer geprüft, es gibt keinen Schalter, das abzustellen. Nutzt die Nextcloud ein Zertifikat einer eigenen (internen) Zertifizierungsstelle, legen Sie deren Zertifikat in den `api`-Container und setzen Sie `NODE_EXTRA_CA_CERTS=/pfad/zur/ca.pem` in dessen Umgebung (Compose-Datei des Servers, danach `up -d --force-recreate api`). Ohne das meldet „Verbindung prüfen“ im Modul, das Zertifikat ließe sich nicht prüfen. - **Nginx Proxy Manager (Tessera-Adresse):** Beim Hochladen schickt der Browser große Dateien in Stücken von 8 MiB durch `/api-proxy`. Der Proxy vor Tessera muss das durchlassen: `client_max_body_size` mindestens `10m` (besser `64m`) und Lese- und Sendezeitlimits (`proxy_read_timeout`, `proxy_send_timeout`) von mindestens 120 Sekunden. Bei der Nginx-Voreinstellung von 1 MiB bricht jeder größere Upload schon beim ersten Stück ab. - **Zusammenbau großer Dateien:** Nextcloud setzt die Stücke am Ende zu einer Datei zusammen. Dauert das länger als etwa 20 Sekunden, antwortet Tessera sofort und die Oberfläche fragt den Fortschritt ab; die Verbindung bleibt also nie lange offen. Unvollständige Uploads (Stücke ohne Abschluss, etwa nach einem Seitenwechsel) räumt Nextcloud nach 24 Stunden selbst auf. -- **Zustand im Arbeitsspeicher:** Offene Browser-Anmeldungen (Zwei-Faktor), laufende Zusammenbauten, die Aufrufsperre gegen die Brute-Force-Sperre der Nextcloud und die zwischengespeicherte Kennung der Nextcloud (10 Minuten) liegen im Prozess des `api`-Containers. Ein Neustart verwirft sie: Wer gerade eine Browser-Anmeldung offen hatte, startet sie neu; angemeldete Benutzer bleiben angemeldet (ihre Zugänge liegen verschlüsselt in der Datenbank). +- **Zustand im Arbeitsspeicher:** Offene Browser-Anmeldungen (Zwei-Faktor), laufende Zusammenbauten, die Aufrufsperre gegen die Brute-Force-Sperre der Nextcloud, die zwischengespeicherte Kennung der Nextcloud (10 Minuten) und noch ausstehende Widerrufe von App-Passwörtern liegen im Prozess des `api`-Containers. Ein Widerruf steht aus, wenn Nextcloud ihn gerade nicht annehmen konnte (Sperre nach „zu viele Anfragen“, Netzfehler, Wartung); Tessera versucht ihn nach dem Ende der Sperre bzw. nach einer Minute erneut, höchstens sechsmal. Ebenso beobachtet Tessera eine abgebrochene Browser-Anmeldung bis zu ihrem Ablauf und widerruft ein dort doch noch ausgestelltes App-Passwort sofort. Ein Neustart verwirft all das: Wer gerade eine Browser-Anmeldung offen hatte, startet sie neu; angemeldete Benutzer bleiben angemeldet (ihre Zugänge liegen verschlüsselt in der Datenbank). Ein dabei verlorener Widerruf bleibt als Gerät im Nextcloud-Konto des Benutzers stehen und lässt sich dort entfernen. - **Brute-Force-Ausnahme in der Nextcloud:** Tragen Sie die Adresse des Tessera-Servers dort in die Ausnahmeliste ein (Administrationshandbuch, Abschnitt „Dateien: Nextcloud anbinden“). Sonst kann eine Reihe falscher Anmeldungen die Nextcloud für alle Benutzer gleichzeitig sperren. - **Verschlüsselung:** Die gespeicherten App-Passwörter sind mit `TESSERA_ENCRYPTION_KEY` verschlüsselt (Kapitel 2). Ein anderer Schlüssel macht sie unlesbar; die Benutzer müssen sich dann neu verbinden.