fix(nextcloud-files): CR-01 Anmeldebremse reserviert Versuche vor dem Nextcloud-Aufruf

- beginPasswordAttempt zaehlt einen Passwortversuch synchron und vor jedem await in
  Benutzer- und Servergrenze; release gibt den Platz bei Erfolg oder Nicht-Fehlversuch frei
- gleichzeitige Fehlversuche kommen nicht mehr an den Grenzen vorbei (3 je Benutzer, 8 je Server)
- Verbinden und Speichern laufen je Benutzer nacheinander (auch Browser-Anmeldung), damit
  zwei gleichzeitige Erfolge kein ungespeichertes, nie widerrufenes App-Passwort hinterlassen
- Specs mit gleichzeitigen Promise.all-Anmeldungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:27:40 +02:00
parent 630398fe93
commit 5c2d327bef
4 changed files with 301 additions and 47 deletions
@@ -70,6 +70,8 @@ export function credentialKeyOf(encryptedAppPassword: string): string {
@Injectable()
export class NextcloudFilesAccountService {
private readonly logger = new Logger(NextcloudFilesAccountService.name);
/** Ende der Warteschlange je Mandant und Benutzer (siehe `withUserLock`). */
private readonly userLocks = new Map<string, Promise<void>>();
constructor(
private readonly prisma: PrismaService,
@@ -159,38 +161,83 @@ export class NextcloudFilesAccountService {
): Promise<NextcloudFilesStatusView> {
const baseUrl = await this.requireBaseUrl(tenantId);
const scope = new URL(baseUrl).origin;
this.guard.checkPasswordAttempt(userId, scope);
// 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 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.
if (issued.kind === 'credentials') this.guard.recordFailure(userId, scope);
throw authFailureToException(issued);
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();
}
}
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));
/**
* 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);
}
await this.storeAppPassword(
tenantId,
userId,
baseUrl,
loginName,
ncUser,
issued.appPassword,
'PASSWORD',
);
this.guard.recordSuccess(userId);
return this.getStatus(tenantId, userId);
}
// --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------
@@ -256,14 +303,17 @@ export class NextcloudFilesAccountService {
return this.failed(authFailureToException(unexpectedCredentials(ncUser)));
}
try {
await this.storeAppPassword(
tenantId,
userId,
entry.baseUrl,
loginName,
ncUser,
appPassword,
'LOGIN_FLOW',
// 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);