fix(nextcloud-files): WR-02/IN-04 Widerrufe auch nach Sperre, Netzfehler und abgebrochener Browser-Anmeldung

- voruebergehend gescheiterte Widerrufe (Aufrufsperre nach 429, Netz, Zeitablauf, Wartung,
  5xx) kommen in eine kleine Warteschlange im Arbeitsspeicher und werden nach dem Ende
  der Sperre bzw. nach einer Minute erneut versucht (hoechstens 200 Eintraege, 6 Versuche,
  nie geloggt)
- abgebrochene, ersetzte oder durch Adresswechsel verworfene Browser-Anmeldungen bleiben
  bis zum Ablauf als "abgebrochen" stehen; der Server fragt sie alle 10 s ab und widerruft
  ein doch noch ausgestelltes App-Passwort sofort; sie zaehlen nicht gegen die 200 offenen
- Specs fuer Sperre, Netzfehler, Aufgeben und den Login-Flow-Abbruch; Betriebshandbuch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:36:32 +02:00
parent c0b8283cf4
commit ddae9400a3
5 changed files with 453 additions and 20 deletions
@@ -804,3 +804,156 @@ describe('Anmeldename = Basic-Benutzer (E-Mail-Anmeldung, gemessen gegen Nextclo
});
});
});
describe('Ausstehende Widerrufe (WR-02)', () => {
function clocked(s: ReturnType<typeof setup>) {
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();
});
});
@@ -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<string, Promise<void>>();
/** 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');
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<void> {
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<ReturnType<typeof pollLoginFlow>>;
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<void> {
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<RevokeOutcome> {
let res: Awaited<ReturnType<typeof revokeAppPassword>>;
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<void> {
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();
}
}
@@ -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');
@@ -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);
}
}
}
+1 -1
View File
@@ -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.