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:
@@ -258,6 +258,93 @@ describe('Verbinden mit Passwort', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Gleichzeitige Anmeldungen (CR-01)', () => {
|
||||||
|
/** Haelt alle Nextcloud-Antworten an, bis `open()` gerufen wird. */
|
||||||
|
function gated(handler: (req: NcTransportRequest) => NcTransportResponse) {
|
||||||
|
let open!: () => void;
|
||||||
|
const opened = new Promise<void>((resolve) => {
|
||||||
|
open = resolve;
|
||||||
|
});
|
||||||
|
const wrapped = (async (req: NcTransportRequest) => {
|
||||||
|
await opened;
|
||||||
|
return handler(req);
|
||||||
|
}) as unknown as (req: NcTransportRequest) => NcTransportResponse;
|
||||||
|
return { wrapped, open };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fuenf gleichzeitige Fehlversuche desselben Benutzers: nur drei erreichen Nextcloud, zwei sind sofort 429', async () => {
|
||||||
|
const g = gated(() => reply(401));
|
||||||
|
const s = setup({ handler: g.wrapped });
|
||||||
|
const all = Promise.all(
|
||||||
|
[0, 1, 2, 3, 4].map(() => errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x'))),
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
g.open();
|
||||||
|
const results = await all;
|
||||||
|
expect(results.filter((r) => r?.status === 429)).toHaveLength(2);
|
||||||
|
expect(results.filter((r) => r?.body.code === 'credentialsOrTwoFactor')).toHaveLength(3);
|
||||||
|
expect(s.calls.filter((c) => c.url.endsWith('/core/getapppassword'))).toHaveLength(3);
|
||||||
|
// Danach bleibt der Benutzer gesperrt, ohne dass Nextcloud gefragt wird.
|
||||||
|
const after = await errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x'));
|
||||||
|
expect(after?.status).toBe(429);
|
||||||
|
expect(s.calls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zehn gleichzeitige Fehlversuche verschiedener Benutzer: hoechstens acht erreichen Nextcloud', async () => {
|
||||||
|
const g = gated(() => reply(401));
|
||||||
|
const s = setup({ handler: g.wrapped });
|
||||||
|
const all = Promise.all(
|
||||||
|
Array.from({ length: 10 }, (_, i) =>
|
||||||
|
errOf(s.service.connectWithPassword('t1', `u${i}`, 'zoe', 'x')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
g.open();
|
||||||
|
const results = await all;
|
||||||
|
expect(s.calls).toHaveLength(8);
|
||||||
|
expect(results.filter((r) => r?.status === 429)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein gelungener Versuch gibt seinen Platz frei', async () => {
|
||||||
|
const s = setup({ handler: happy });
|
||||||
|
for (let i = 0; i < 5; i++) await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim');
|
||||||
|
expect(s.calls.filter((c) => c.url.endsWith('/core/getapppassword'))).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zwei gleichzeitige erfolgreiche Anmeldungen: eine Zeile, das zuerst ausgestellte App-Passwort ist widerrufen', async () => {
|
||||||
|
let issued = 0;
|
||||||
|
const g = gated((req) => {
|
||||||
|
if (req.url.endsWith('/core/getapppassword')) {
|
||||||
|
issued += 1;
|
||||||
|
return reply(200, ocs({ apppassword: `app-pw-${issued}` }));
|
||||||
|
}
|
||||||
|
return happy(req);
|
||||||
|
});
|
||||||
|
const s = setup({ handler: g.wrapped });
|
||||||
|
const both = Promise.all([
|
||||||
|
s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'),
|
||||||
|
s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'),
|
||||||
|
]);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
g.open();
|
||||||
|
await both;
|
||||||
|
expect(issued).toBe(2);
|
||||||
|
expect(s.rows).toHaveLength(1);
|
||||||
|
const stored = s.rows[0].encryptedAppPassword;
|
||||||
|
const deletes = s.calls.filter((c) => c.method === 'DELETE');
|
||||||
|
// Genau ein Widerruf: das Passwort, das NICHT gespeichert ist.
|
||||||
|
expect(deletes).toHaveLength(1);
|
||||||
|
const revokedSecret = Buffer.from(
|
||||||
|
deletes[0].headers.authorization.replace('Basic ', ''),
|
||||||
|
'base64',
|
||||||
|
)
|
||||||
|
.toString('utf8')
|
||||||
|
.split(':')[1];
|
||||||
|
expect(stored).toBe('enc(app-pw-2)');
|
||||||
|
expect(revokedSecret).toBe('app-pw-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('App-Passwort-Hygiene (D-P)', () => {
|
describe('App-Passwort-Hygiene (D-P)', () => {
|
||||||
it('cloud/user scheitert nach erfolgreichem getapppassword: genau ein Widerruf, kein Upsert', async () => {
|
it('cloud/user scheitert nach erfolgreichem getapppassword: genau ein Widerruf, kein Upsert', async () => {
|
||||||
const s = setup({
|
const s = setup({
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export function credentialKeyOf(encryptedAppPassword: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class NextcloudFilesAccountService {
|
export class NextcloudFilesAccountService {
|
||||||
private readonly logger = new Logger(NextcloudFilesAccountService.name);
|
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(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -159,14 +161,25 @@ export class NextcloudFilesAccountService {
|
|||||||
): Promise<NextcloudFilesStatusView> {
|
): Promise<NextcloudFilesStatusView> {
|
||||||
const baseUrl = await this.requireBaseUrl(tenantId);
|
const baseUrl = await this.requireBaseUrl(tenantId);
|
||||||
const scope = new URL(baseUrl).origin;
|
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 issued = await getAppPassword(this.transport, this.gate, baseUrl, loginName, password);
|
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) {
|
if (!issued.ok) {
|
||||||
// 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als Fehlanmeldung.
|
// 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als
|
||||||
if (issued.kind === 'credentials') this.guard.recordFailure(userId, scope);
|
// Fehlanmeldung; bei einer Zeitueberschreitung kann Nextcloud ihn schon gezaehlt haben.
|
||||||
|
if (issued.kind === 'credentials' || issued.kind === 'timeout') attempt.fail();
|
||||||
throw authFailureToException(issued);
|
throw authFailureToException(issued);
|
||||||
}
|
}
|
||||||
|
attempt.release();
|
||||||
|
|
||||||
const ncUser = await getCurrentUser(
|
const ncUser = await getCurrentUser(
|
||||||
this.transport,
|
this.transport,
|
||||||
@@ -191,6 +204,40 @@ export class NextcloudFilesAccountService {
|
|||||||
);
|
);
|
||||||
this.guard.recordSuccess(userId);
|
this.guard.recordSuccess(userId);
|
||||||
return this.getStatus(tenantId, 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) ------------------------------------------------------
|
// --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------
|
||||||
@@ -256,7 +303,9 @@ export class NextcloudFilesAccountService {
|
|||||||
return this.failed(authFailureToException(unexpectedCredentials(ncUser)));
|
return this.failed(authFailureToException(unexpectedCredentials(ncUser)));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.storeAppPassword(
|
// Dieselbe Warteschlange wie die Passwort-Anmeldung (CR-01): nie zwei Speichervorgaenge zugleich.
|
||||||
|
await this.withUserLock(tenantId, userId, () =>
|
||||||
|
this.storeAppPassword(
|
||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
entry.baseUrl,
|
entry.baseUrl,
|
||||||
@@ -264,6 +313,7 @@ export class NextcloudFilesAccountService {
|
|||||||
ncUser,
|
ncUser,
|
||||||
appPassword,
|
appPassword,
|
||||||
'LOGIN_FLOW',
|
'LOGIN_FLOW',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return this.failed(err);
|
return this.failed(err);
|
||||||
|
|||||||
@@ -82,6 +82,53 @@ describe('NextcloudLoginGuard — Passwort-Fehlversuche', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NextcloudLoginGuard — Reservierung (CR-01)', () => {
|
||||||
|
it('laufende Versuche zaehlen sofort: der 4. gleichzeitige Versuch von u1 ist 429', () => {
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
const running = [0, 1, 2].map(() => guard.beginPasswordAttempt('u1'));
|
||||||
|
expect(running).toHaveLength(3);
|
||||||
|
const blocked = codeOf(() => guard.beginPasswordAttempt('u1'));
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
expect(blocked.body.code).toBe('tooManyAttempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release gibt den Platz frei, fail behaelt ihn', () => {
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
const a = guard.beginPasswordAttempt('u1');
|
||||||
|
const b = guard.beginPasswordAttempt('u1');
|
||||||
|
const c = guard.beginPasswordAttempt('u1');
|
||||||
|
a.release();
|
||||||
|
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||||
|
b.fail();
|
||||||
|
c.fail();
|
||||||
|
// fail nach release (und umgekehrt) aendert nichts mehr
|
||||||
|
a.fail();
|
||||||
|
b.release();
|
||||||
|
const d = guard.beginPasswordAttempt('u1');
|
||||||
|
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429);
|
||||||
|
d.release();
|
||||||
|
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Servergrenze gilt auch fuer gleichzeitige Versuche verschiedener Benutzer', () => {
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
for (let i = 0; i < 8; i++) guard.beginPasswordAttempt(`u${i}`, 'http://a.example');
|
||||||
|
expect(codeOf(() => guard.beginPasswordAttempt('u9', 'http://a.example')).status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordSuccess laesst laufende Versuche desselben Benutzers stehen', () => {
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
guard.recordFailure('u1');
|
||||||
|
guard.recordFailure('u1');
|
||||||
|
guard.beginPasswordAttempt('u1');
|
||||||
|
guard.recordSuccess('u1');
|
||||||
|
// eine abgeschlossene Fehlanmeldung weg, der laufende Versuch zaehlt noch: 1 von 3
|
||||||
|
guard.beginPasswordAttempt('u1');
|
||||||
|
guard.beginPasswordAttempt('u1');
|
||||||
|
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => {
|
describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => {
|
||||||
it('der 11. Start eines Benutzers binnen 10 Minuten ist 429, andere Benutzer nicht', () => {
|
it('der 11. Start eines Benutzers binnen 10 Minuten ist 429, andere Benutzer nicht', () => {
|
||||||
const { guard, clock } = makeGuard();
|
const { guard, clock } = makeGuard();
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ import { ncErrorDefault } from './nextcloud-files.types';
|
|||||||
* Der Login Flow v2 zaehlt bei der Nextcloud nicht als Fehlanmeldung (gemessen),
|
* Der Login Flow v2 zaehlt bei der Nextcloud nicht als Fehlanmeldung (gemessen),
|
||||||
* bekommt deshalb nur eine eigene Startgrenze (10 je Benutzer in 10 Minuten)
|
* bekommt deshalb nur eine eigene Startgrenze (10 je Benutzer in 10 Minuten)
|
||||||
* und beruehrt die Fehlerzaehler nie.
|
* und beruehrt die Fehlerzaehler nie.
|
||||||
|
*
|
||||||
|
* RESERVIEREN STATT NACHTRAGEN (CR-01): Ein Passwortversuch wird mit
|
||||||
|
* `beginPasswordAttempt` SYNCHRON und VOR dem Nextcloud-Aufruf gezaehlt — er
|
||||||
|
* belegt sofort einen Platz in der Benutzer- und in der Servergrenze. Erst
|
||||||
|
* wenn klar ist, dass Nextcloud den Versuch nicht als Fehlanmeldung zaehlt
|
||||||
|
* (Erfolg, 403, Netzfehler vor der Antwort ...), wird der Platz mit `release`
|
||||||
|
* zurueckgegeben. Wuerde erst nach der Antwort gezaehlt, kaemen beliebig viele
|
||||||
|
* gleichzeitige Anfragen an beiden Grenzen vorbei, solange keine davon
|
||||||
|
* zurueck ist.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const USER_FAILURE_LIMIT = 3;
|
export const USER_FAILURE_LIMIT = 3;
|
||||||
@@ -29,10 +38,34 @@ export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000;
|
|||||||
export const FLOW_START_LIMIT = 10;
|
export const FLOW_START_LIMIT = 10;
|
||||||
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
function prune(list: number[], now: number, windowMs: number): number[] {
|
/** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */
|
||||||
|
interface Attempt {
|
||||||
|
at: number;
|
||||||
|
inFlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein reservierter Passwortversuch (siehe `beginPasswordAttempt`). */
|
||||||
|
export interface PasswordAttempt {
|
||||||
|
/** Nextcloud hat den Versuch als Fehlanmeldung gezaehlt: der Platz bleibt belegt. */
|
||||||
|
fail(): void;
|
||||||
|
/** Kein Fehlversuch (Erfolg oder Abbruch vor der Pruefung): der Platz wird frei. */
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(list: Attempt[], now: number, windowMs: number): Attempt[] {
|
||||||
|
return list.filter((a) => now - a.at < windowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneTimes(list: number[], now: number, windowMs: number): number[] {
|
||||||
return list.filter((t) => now - t < windowMs);
|
return list.filter((t) => now - t < windowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeAttempt(list: Attempt[] | undefined, attempt: Attempt): void {
|
||||||
|
if (!list) return;
|
||||||
|
const i = list.indexOf(attempt);
|
||||||
|
if (i >= 0) list.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
function tooMany(retryAfterMs: number) {
|
function tooMany(retryAfterMs: number) {
|
||||||
return ncErrorDefault('tooManyAttempts', {
|
return ncErrorDefault('tooManyAttempts', {
|
||||||
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||||
@@ -44,15 +77,16 @@ export class NextcloudLoginGuard {
|
|||||||
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
|
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
|
||||||
now: () => number = () => Date.now();
|
now: () => number = () => Date.now();
|
||||||
|
|
||||||
private readonly userFailures = new Map<string, number[]>();
|
private readonly userFailures = new Map<string, Attempt[]>();
|
||||||
private readonly serverFailures = new Map<string, number[]>();
|
private readonly serverFailures = new Map<string, Attempt[]>();
|
||||||
private readonly flowStarts = new Map<string, number[]>();
|
private readonly flowStarts = new Map<string, number[]>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
||||||
* `tooManyAttempts` mit `retryAfterSeconds` (bis der aelteste gezaehlte
|
* `tooManyAttempts` mit `retryAfterSeconds` (bis der aelteste gezaehlte
|
||||||
* Fehlversuch das Fenster verlaesst). `scope` trennt Server, die verschiedene
|
* Fehlversuch das Fenster verlaesst). `scope` trennt Server, die verschiedene
|
||||||
* Nextclouds ansprechen (Standard: eine gemeinsame Sperre).
|
* Nextclouds ansprechen (Standard: eine gemeinsame Sperre). Laufende,
|
||||||
|
* reservierte Versuche zaehlen mit.
|
||||||
*/
|
*/
|
||||||
checkPasswordAttempt(userId: string, scope = ''): void {
|
checkPasswordAttempt(userId: string, scope = ''): void {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
@@ -63,33 +97,69 @@ export class NextcloudLoginGuard {
|
|||||||
|
|
||||||
let waitMs = 0;
|
let waitMs = 0;
|
||||||
if (mine.length >= USER_FAILURE_LIMIT) {
|
if (mine.length >= USER_FAILURE_LIMIT) {
|
||||||
waitMs = Math.max(waitMs, mine[0] + USER_FAILURE_WINDOW_MS - now);
|
waitMs = Math.max(waitMs, mine[0].at + USER_FAILURE_WINDOW_MS - now);
|
||||||
}
|
}
|
||||||
if (server.length >= SERVER_FAILURE_LIMIT) {
|
if (server.length >= SERVER_FAILURE_LIMIT) {
|
||||||
waitMs = Math.max(waitMs, server[0] + SERVER_FAILURE_WINDOW_MS - now);
|
waitMs = Math.max(waitMs, server[0].at + SERVER_FAILURE_WINDOW_MS - now);
|
||||||
}
|
}
|
||||||
if (waitMs > 0) throw tooMany(waitMs);
|
if (waitMs > 0) throw tooMany(waitMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die Grenzen UND belegt sofort einen Platz in beiden (synchron, ohne
|
||||||
|
* `await` dazwischen). Wirft 429 `tooManyAttempts`, wenn eine Grenze voll ist —
|
||||||
|
* dann wird Nextcloud gar nicht erst gefragt.
|
||||||
|
*/
|
||||||
|
beginPasswordAttempt(userId: string, scope = ''): PasswordAttempt {
|
||||||
|
this.checkPasswordAttempt(userId, scope);
|
||||||
|
const attempt: Attempt = { at: this.now(), inFlight: true };
|
||||||
|
const mine = this.userFailures.get(userId) ?? [];
|
||||||
|
mine.push(attempt);
|
||||||
|
this.userFailures.set(userId, mine);
|
||||||
|
const server = this.serverFailures.get(scope) ?? [];
|
||||||
|
server.push(attempt);
|
||||||
|
this.serverFailures.set(scope, server);
|
||||||
|
let settled = false;
|
||||||
|
return {
|
||||||
|
fail: () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
attempt.inFlight = false;
|
||||||
|
},
|
||||||
|
release: () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
removeAttempt(this.userFailures.get(userId), attempt);
|
||||||
|
removeAttempt(this.serverFailures.get(scope), attempt);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
recordFailure(userId: string, scope = ''): void {
|
recordFailure(userId: string, scope = ''): void {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
|
const attempt: Attempt = { at: now, inFlight: false };
|
||||||
const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS);
|
const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS);
|
||||||
mine.push(now);
|
mine.push(attempt);
|
||||||
this.userFailures.set(userId, mine);
|
this.userFailures.set(userId, mine);
|
||||||
const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS);
|
const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS);
|
||||||
server.push(now);
|
server.push(attempt);
|
||||||
this.serverFailures.set(scope, server);
|
this.serverFailures.set(scope, server);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine gelungene Anmeldung loescht nur die Fehlversuche dieses Benutzers. */
|
/**
|
||||||
|
* Eine gelungene Anmeldung loescht nur die abgeschlossenen Fehlversuche dieses
|
||||||
|
* Benutzers; noch laufende Versuche bleiben gezaehlt.
|
||||||
|
*/
|
||||||
recordSuccess(userId: string): void {
|
recordSuccess(userId: string): void {
|
||||||
this.userFailures.delete(userId);
|
const pending = (this.userFailures.get(userId) ?? []).filter((a) => a.inFlight);
|
||||||
|
if (pending.length === 0) this.userFailures.delete(userId);
|
||||||
|
else this.userFailures.set(userId, pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */
|
/** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */
|
||||||
checkFlowStart(userId: string): void {
|
checkFlowStart(userId: string): void {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const starts = prune(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS);
|
const starts = pruneTimes(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS);
|
||||||
if (starts.length >= FLOW_START_LIMIT) {
|
if (starts.length >= FLOW_START_LIMIT) {
|
||||||
this.flowStarts.set(userId, starts);
|
this.flowStarts.set(userId, starts);
|
||||||
throw tooMany(starts[0] + FLOW_START_WINDOW_MS - now);
|
throw tooMany(starts[0] + FLOW_START_WINDOW_MS - now);
|
||||||
|
|||||||
Reference in New Issue
Block a user