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
@@ -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');
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<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();
}
}