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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user