Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts
T
schalli ddae9400a3 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>
2026-10-08 22:36:32 +02:00

714 lines
26 KiB
TypeScript

import { createHash } from 'node:crypto';
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';
import {
type AuthFailure,
authFailureCode,
authFailureToException,
getAppPassword,
getCurrentUser,
pollLoginFlow,
revokeAppPassword,
startLoginFlow,
} from './nextcloud-auth-client';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
type NcSession,
type NextcloudFilesAccountView,
type NextcloudFilesStatusView,
ncErrorDefault,
} from './nextcloud-files.types';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
import { basicAuth, NEXTCLOUD_TRANSPORT, type NextcloudTransport } from './nextcloud-http';
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
type ConnectMethod = 'PASSWORD' | 'LOGIN_FLOW';
interface AccountRow {
baseUrl: string;
ncUserId: string;
/** Basic-Benutzer des App-Passworts (Anmeldename bei der Ausstellung); null = ncUserId. */
ncLoginName: string | null;
ncDisplayName: string | null;
encryptedAppPassword: string;
status: 'ACTIVE' | 'EXPIRED';
connectedVia: ConnectMethod;
createdAt: Date;
updatedAt: Date;
}
export type FlowPollResult =
| { state: 'pending' }
| { 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);
}
/**
* Konto je Benutzer (quick-261008-mzu): verbinden mit Passwort oder per
* Browser-Anmeldung (Login Flow v2), trennen mit Widerruf, Sitzung fuer die
* Dateiaufrufe. Gesamter Zugriff auf `nextcloudFilesAccount` mit der
* Benutzerkennung aus dem Token liegt ausschliesslich hier — jede Methode
* bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die
* Zeilenregel laesst nur eigene Zeilen zu, und jedes `where` traegt beides.
*
* Geheimnisse: das echte Passwort lebt nur in `connectWithPassword`, das App-
* Passwort wird mit `CryptoService.encrypt` abgelegt und nur in `getSession`
* entschluesselt. Nichts davon steht in einer Antwort, einem Log oder einem
* Fehler.
*
* App-Passwort-Hygiene (D-P): ein frisch ausgestelltes App-Passwort, das nicht
* 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 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,
private readonly crypto: CryptoService,
private readonly settings: NextcloudFilesSettingsService,
private readonly guard: NextcloudLoginGuard,
private readonly flows: LoginFlowStore,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {
// 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) ----------
private async findAccount(tenantId: string, userId: string): Promise<AccountRow | null> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await tenantPrisma.nextcloudFilesAccount.findFirst({ where: { tenantId, userId } });
return (row as AccountRow | null) ?? null;
}
private async upsertAccount(
tenantId: string,
userId: string,
data: {
baseUrl: string;
ncUserId: string;
ncLoginName: string;
ncDisplayName: string | null;
encryptedAppPassword: string;
connectedVia: ConnectMethod;
},
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await tenantPrisma.nextcloudFilesAccount.upsert({
where: { tenantId_userId: { tenantId, userId } },
create: { tenantId, userId, ...data, status: 'ACTIVE' },
update: { ...data, status: 'ACTIVE' },
});
}
private async deleteAccount(tenantId: string, userId: string): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await tenantPrisma.nextcloudFilesAccount.deleteMany({ where: { tenantId, userId } });
}
/** Markiert das eigene Konto als abgelaufen (App-Passwort wurde von Nextcloud abgelehnt). */
async markExpired(tenantId: string, userId: string): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await tenantPrisma.nextcloudFilesAccount.updateMany({
where: { tenantId, userId, status: 'ACTIVE' },
data: { status: 'EXPIRED' },
});
}
// --- Stand ------------------------------------------------------------------------------
async getStatus(tenantId: string, userId: string): Promise<NextcloudFilesStatusView> {
const base = await this.settings.getStatus(tenantId);
if (!base.configured) return { ...base, account: null };
const row = await this.findAccount(tenantId, userId);
if (!row) return { ...base, account: null };
const expired =
row.status !== 'ACTIVE' ||
row.baseUrl !== base.serverUrl ||
this.gate.isDead(credentialKeyOf(row.encryptedAppPassword));
const account: NextcloudFilesAccountView = {
connected: !expired,
expired,
status: expired ? 'EXPIRED' : 'ACTIVE',
ncUserId: row.ncUserId,
displayName: row.ncDisplayName,
connectedVia: row.connectedVia,
connectedAt: row.updatedAt.toISOString(),
};
return { ...base, account };
}
// --- Verbinden mit Passwort -------------------------------------------------------------------
async connectWithPassword(
tenantId: string,
userId: string,
loginName: string,
password: string,
): Promise<NextcloudFilesStatusView> {
const baseUrl = await this.requireBaseUrl(tenantId);
const scope = new URL(baseUrl).origin;
// CR-01: den Versuch SYNCHRON reservieren, bevor irgendetwas wartet. Gleichzeitige
// Anfragen sehen so die laufenden Versuche und bekommen 429, statt alle an Nextcloud zu gehen.
const attempt = this.guard.beginPasswordAttempt(userId, scope);
try {
return await this.withUserLock(tenantId, userId, async () => {
const issued = await getAppPassword(
this.transport,
this.gate,
baseUrl,
loginName,
password,
);
if (!issued.ok) {
// 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als
// Fehlanmeldung; bei einer Zeitueberschreitung kann Nextcloud ihn schon gezaehlt haben.
if (issued.kind === 'credentials' || issued.kind === 'timeout') attempt.fail();
throw authFailureToException(issued);
}
attempt.release();
const ncUser = await getCurrentUser(
this.transport,
this.gate,
baseUrl,
loginName,
issued.appPassword,
);
if (!ncUser.ok) {
await this.revokeFresh(baseUrl, loginName, issued.appPassword);
throw authFailureToException(unexpectedCredentials(ncUser));
}
await this.storeAppPassword(
tenantId,
userId,
baseUrl,
loginName,
ncUser,
issued.appPassword,
'PASSWORD',
);
this.guard.recordSuccess(userId);
return this.getStatus(tenantId, userId);
});
} finally {
// Jeder andere Ausgang (403, Netzfehler, gesperrt ...) ist kein Fehlversuch; nach `fail` wirkungslos.
attempt.release();
}
}
/**
* Verbindungsvorgaenge desselben Benutzers laufen nacheinander (CR-01). Ohne das
* koennten zwei gleichzeitig erfolgreiche Anmeldungen beide dieselbe alte Zeile
* widerrufen und dann nacheinander speichern — das zuerst gespeicherte frische
* App-Passwort waere ueberschrieben, nirgends abgelegt und nie widerrufen. So
* widerruft der zweite Vorgang das Passwort des ersten ganz regulaer als "altes".
*/
private async withUserLock<T>(
tenantId: string,
userId: string,
fn: () => Promise<T>,
): Promise<T> {
const key = `${tenantId}:${userId}`;
const previous = this.userLocks.get(key) ?? Promise.resolve();
let unlock!: () => void;
const mine = new Promise<void>((resolve) => {
unlock = resolve;
});
const tail = previous.then(() => mine);
this.userLocks.set(key, tail);
try {
await previous;
return await fn();
} finally {
unlock();
if (this.userLocks.get(key) === tail) this.userLocks.delete(key);
}
}
// --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------
async startFlow(
tenantId: string,
userId: string,
): Promise<{ flowId: string; loginUrl: string; expiresAt: string }> {
const baseUrl = await this.requireBaseUrl(tenantId);
this.guard.checkFlowStart(userId);
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,
expiresAt: new Date(entry.expiresAt).toISOString(),
};
}
async pollFlow(tenantId: string, userId: string, flowId: string): Promise<FlowPollResult> {
const found = this.flows.lookup(flowId, tenantId, userId);
if (found.state === 'missing') throw ncErrorDefault('notFound');
if (found.state === 'expired') {
this.flows.remove(flowId);
throw ncErrorDefault('flowExpired');
}
const entry = found.entry;
// Die Adresse darf sich seit dem Start nicht geaendert haben.
const current = await this.settings.getBaseUrl(tenantId);
if (current !== entry.baseUrl) {
this.flows.cancel(flowId);
this.scheduleFlowSweep();
throw ncErrorDefault('flowExpired');
}
if (!this.flows.shouldPoll(entry)) return { state: 'pending' };
this.flows.markPolled(entry);
const polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken);
if (!polled.ok) throw authFailureToException(polled);
if (polled.state === 'pending') return { state: 'pending' };
// Bestaetigt. Der Ablauf ist verbraucht (Nextcloud gibt das Ergebnis nur einmal heraus).
const stillOpen = this.flows.get(flowId, tenantId, userId) !== undefined;
this.flows.remove(flowId);
const { loginName, appPassword } = polled;
if (!stillOpen) {
// Zwischenzeitlich abgebrochen: der frisch ausgestellte Zugang darf nirgends liegen bleiben.
await this.revokeFresh(entry.baseUrl, loginName, appPassword);
return this.failed(ncErrorDefault('flowExpired'));
}
const ncUser = await getCurrentUser(
this.transport,
this.gate,
entry.baseUrl,
loginName,
appPassword,
);
if (!ncUser.ok) {
await this.revokeFresh(entry.baseUrl, loginName, appPassword);
return this.failed(authFailureToException(unexpectedCredentials(ncUser)));
}
try {
// Dieselbe Warteschlange wie die Passwort-Anmeldung (CR-01): nie zwei Speichervorgaenge zugleich.
await this.withUserLock(tenantId, userId, () =>
this.storeAppPassword(
tenantId,
userId,
entry.baseUrl,
loginName,
ncUser,
appPassword,
'LOGIN_FLOW',
),
);
} catch (err) {
return this.failed(err);
}
return { state: 'connected' };
}
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 };
return {
state: 'failed',
code: body.code ?? 'nextcloudError',
message: body.message ?? ncErrorDefault('nextcloudError').message,
};
}
const fallback = ncErrorDefault('nextcloudError');
return { state: 'failed', code: 'nextcloudError', message: fallback.message };
}
// --- Trennen ----------------------------------------------------------------------------------
async disconnect(tenantId: string, userId: string): Promise<{ disconnected: true }> {
const row = await this.findAccount(tenantId, userId);
if (!row) throw ncErrorDefault('notConnected');
const current = await this.settings.getBaseUrl(tenantId);
// Widerrufen nur dort, wo der Zugang gilt: aktives Konto UND gleiche Adresse (nie an einen anderen Host).
if (row.status === 'ACTIVE' && current !== null && current === row.baseUrl) {
let appPassword: string | null = null;
try {
appPassword = this.crypto.decrypt(row.encryptedAppPassword);
} catch {
this.logger.error(
`App-Passwort eines Kontos ließ sich nicht entschlüsseln (Mandant ${tenantId}); Konto wird ohne Widerruf entfernt`,
);
}
if (appPassword !== null) {
await this.revokeBestEffort(
row.baseUrl,
basicUserOf(row),
appPassword,
credentialKeyOf(row.encryptedAppPassword),
);
}
}
await this.deleteAccount(tenantId, userId);
return { disconnected: true };
}
// --- Sitzung fuer die Dateiaufrufe --------------------------------------------------------------
async getSession(tenantId: string, userId: string): Promise<NcSession> {
const baseUrl = await this.requireBaseUrl(tenantId);
const row = await this.findAccount(tenantId, userId);
if (!row) throw ncErrorDefault('notConnected');
if (row.status !== 'ACTIVE' || row.baseUrl !== baseUrl) {
throw ncErrorDefault('connectionExpired');
}
const credentialKey = credentialKeyOf(row.encryptedAppPassword);
if (this.gate.isDead(credentialKey)) {
await this.markExpired(tenantId, userId);
throw ncErrorDefault('connectionExpired');
}
let appPassword: string;
try {
appPassword = this.crypto.decrypt(row.encryptedAppPassword);
} catch {
this.logger.error(`Gespeichertes App-Passwort ist nicht lesbar (Mandant ${tenantId})`);
throw ncErrorDefault('accountBroken');
}
return {
baseUrl: row.baseUrl,
ncUserId: row.ncUserId,
authorization: basicAuth(basicUserOf(row), appPassword),
credentialKey,
};
}
// --- Hilfen ---------------------------------------------------------------------------------------
private async requireBaseUrl(tenantId: string): Promise<string> {
const baseUrl = await this.settings.getBaseUrl(tenantId);
if (baseUrl === null) throw ncErrorDefault('notConfigured');
return baseUrl;
}
/**
* App-Passwort ablegen (D-P): zuerst das alte Passwort derselben Adresse
* widerrufen, dann verschluesseln und speichern. Scheitert etwas, wird das
* FRISCHE Passwort sofort widerrufen und der Fehler weitergegeben.
*/
private async storeAppPassword(
tenantId: string,
userId: string,
baseUrl: string,
loginName: string,
ncUser: { id: string; displayName: string | null },
appPassword: string,
method: ConnectMethod,
): Promise<void> {
try {
await this.revokePrevious(tenantId, userId, baseUrl);
const encryptedAppPassword = this.crypto.encrypt(appPassword);
await this.upsertAccount(tenantId, userId, {
baseUrl,
ncUserId: ncUser.id,
ncLoginName: loginName,
ncDisplayName: ncUser.displayName,
encryptedAppPassword,
connectedVia: method,
});
} catch (err) {
await this.revokeFresh(baseUrl, loginName, appPassword);
throw err;
}
}
/** Das alte App-Passwort derselben Adresse widerrufen (best effort, nie ein Fehler nach aussen). */
private async revokePrevious(tenantId: string, userId: string, baseUrl: string): Promise<void> {
let old: AccountRow | null;
try {
old = await this.findAccount(tenantId, userId);
} catch {
return;
}
if (!old || old.baseUrl !== baseUrl) return;
let oldPassword: string;
try {
oldPassword = this.crypto.decrypt(old.encryptedAppPassword);
} catch {
this.logger.warn(`Altes App-Passwort nicht lesbar (Mandant ${tenantId}); kein Widerruf`);
return;
}
await this.revokeBestEffort(
old.baseUrl,
basicUserOf(old),
oldPassword,
credentialKeyOf(old.encryptedAppPassword),
);
}
private async revokeFresh(
baseUrl: string,
loginName: string,
appPassword: string,
): Promise<void> {
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 {
res = await revokeAppPassword(
this.transport,
this.gate,
baseUrl,
loginName,
appPassword,
credentialKey,
);
} catch {
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();
}
}
/**
* Der Basic-Benutzer eines App-Passworts ist der Anmeldename der Ausstellung
* (gemessen: mit der E-Mail-Adresse ausgestellt, antwortet Nextcloud auf die
* Kennung mit 401). Konten vor dieser Spalte haben keinen: dann gilt die Kennung.
*/
function basicUserOf(row: Pick<AccountRow, 'ncUserId' | 'ncLoginName'>): string {
return row.ncLoginName ?? row.ncUserId;
}
function failureLabel(failure: AuthFailure): string {
return authFailureCode(failure);
}
/**
* Ein 401 auf `cloud/user` mit einem GERADE ausgestellten App-Passwort ist kein
* "falsches Passwort" fuer den Benutzer, sondern eine unerwartete Antwort.
*/
function unexpectedCredentials(failure: AuthFailure): AuthFailure {
return failure.kind === 'credentials' ? { ...failure, kind: 'upstream' } : failure;
}