feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf
- Anmelde-Client (getapppassword, cloud/user, Widerruf, Login Flow v2 mit fester Abfrageadresse, Link aus Basis und Token neu gebaut), Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server, Ablaufspeicher für Browser-Anmeldungen (20 min, höchstens 200, eine je Benutzer) - Kontodienst: Verbinden, Trennen mit Widerruf, Sitzung mit Zugangsschlüssel-Sperre, frisch ausgestellte oder ersetzte App-Passwörter bleiben nie verwaist; jeder Kontozugriff über forTenant mit Mandant UND Benutzer aus dem Token - Migration 20261008183000: Spalte ncLoginName (App-Passwort gilt nur für den Anmeldenamen der Ausstellung, gemessen mit E-Mail-Anmeldung gegen Nextcloud 34) - Verbindungsbildschirm und Kontoleiste, Texte de/en, RLS-Inventar fortgeschrieben, E2E-Skript e2e-connect.sh Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FLOW_MAX_TOTAL,
|
||||
FLOW_TTL_MS,
|
||||
LoginFlowStore,
|
||||
NextcloudLoginGuard,
|
||||
} from './nextcloud-login-guard';
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
function codeOf(fn: () => unknown): { status?: number; body?: any } {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return { status: (e as any).getStatus?.(), body: (e as any).getResponse?.() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function makeGuard() {
|
||||
const guard = new NextcloudLoginGuard();
|
||||
const clock = { t: 1_000_000 };
|
||||
guard.now = () => clock.t;
|
||||
return { guard, clock };
|
||||
}
|
||||
|
||||
describe('NextcloudLoginGuard — Passwort-Fehlversuche', () => {
|
||||
it('3 Fehlversuche von u1: der 4. Versuch ist 429 mit Wartezeit, u2 darf noch', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
guard.checkPasswordAttempt('u1');
|
||||
guard.recordFailure('u1');
|
||||
clock.t += 1000;
|
||||
}
|
||||
const blocked = codeOf(() => guard.checkPasswordAttempt('u1'));
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.body.code).toBe('tooManyAttempts');
|
||||
expect(blocked.body.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(blocked.body.retryAfterSeconds).toBeLessThanOrEqual(15 * 60);
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u2')).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('nach 15 Minuten darf u1 wieder', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 3; i++) guard.recordFailure('u1');
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429);
|
||||
clock.t += 15 * MIN + 1;
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('8 Fehlversuche verteilt auf Benutzer binnen 30 Minuten sperren jeden', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
guard.recordFailure(`u${i}`);
|
||||
clock.t += 60 * 1000;
|
||||
}
|
||||
const blocked = codeOf(() => guard.checkPasswordAttempt('neu'));
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.body.code).toBe('tooManyAttempts');
|
||||
clock.t += 30 * MIN;
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('neu')).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recordSuccess loescht nur die Fehlversuche dieses Benutzers', () => {
|
||||
const { guard } = makeGuard();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
guard.recordFailure('u1');
|
||||
guard.recordFailure('u2');
|
||||
}
|
||||
guard.recordSuccess('u1');
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u2')).status).toBe(429);
|
||||
});
|
||||
|
||||
it('getrennte Nextcloud-Ursprünge teilen die Serversperre nicht', () => {
|
||||
const { guard } = makeGuard();
|
||||
for (let i = 0; i < 8; i++) guard.recordFailure(`u${i}`, 'http://a.example');
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('x', 'http://a.example')).status).toBe(429);
|
||||
expect(
|
||||
codeOf(() => guard.checkPasswordAttempt('x', 'http://b.example')).status,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => {
|
||||
it('der 11. Start eines Benutzers binnen 10 Minuten ist 429, andere Benutzer nicht', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
guard.checkFlowStart('u1');
|
||||
clock.t += 1000;
|
||||
}
|
||||
const blocked = codeOf(() => guard.checkFlowStart('u1'));
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.body.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(codeOf(() => guard.checkFlowStart('u2')).status).toBeUndefined();
|
||||
clock.t += 10 * MIN;
|
||||
expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('beruehrt die Fehlerzaehler nie', () => {
|
||||
const { guard } = makeGuard();
|
||||
for (let i = 0; i < 10; i++) guard.checkFlowStart('u1');
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginFlowStore', () => {
|
||||
function makeStore() {
|
||||
const store = new LoginFlowStore();
|
||||
const clock = { t: 5_000_000 };
|
||||
store.now = () => clock.t;
|
||||
return { store, clock };
|
||||
}
|
||||
|
||||
it('create liefert eine uuid; ein zweiter Start desselben Benutzers ersetzt den ersten', () => {
|
||||
const { store } = makeStore();
|
||||
const a = store.create('t1', 'u1', 'http://c.example', 'tok-a');
|
||||
expect(a.flowId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
const b = store.create('t1', 'u1', 'http://c.example', 'tok-b');
|
||||
expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined();
|
||||
expect(store.get(b.flowId, 't1', 'u1')?.pollToken).toBe('tok-b');
|
||||
});
|
||||
|
||||
it('fremder Benutzer oder Mandant: nichts (wie unbekannt)', () => {
|
||||
const { store } = makeStore();
|
||||
const a = store.create('t1', 'u1', 'http://c.example', 'tok');
|
||||
expect(store.get(a.flowId, 't1', 'u2')).toBeUndefined();
|
||||
expect(store.get(a.flowId, 't2', 'u1')).toBeUndefined();
|
||||
expect(store.lookup(a.flowId, 't1', 'u2')).toEqual({ state: 'missing' });
|
||||
});
|
||||
|
||||
it('nach 20 Minuten abgelaufen', () => {
|
||||
const { store, clock } = makeStore();
|
||||
const a = store.create('t1', 'u1', 'http://c.example', 'tok');
|
||||
clock.t += FLOW_TTL_MS - 1;
|
||||
expect(store.lookup(a.flowId, 't1', 'u1').state).toBe('ok');
|
||||
clock.t += 2;
|
||||
expect(store.lookup(a.flowId, 't1', 'u1')).toEqual({ state: 'expired' });
|
||||
expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('der 201. Ablauf ist 503 tooManyFlows', () => {
|
||||
const { store } = makeStore();
|
||||
for (let i = 0; i < FLOW_MAX_TOTAL; i++) store.create('t1', `u${i}`, 'http://c.example', 'tok');
|
||||
const res = codeOf(() => store.create('t1', 'neu', 'http://c.example', 'tok'));
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('tooManyFlows');
|
||||
// Ein bestehender Benutzer ersetzt seinen Ablauf weiterhin.
|
||||
expect(
|
||||
codeOf(() => store.create('t1', 'u0', 'http://c.example', 'tok')).status,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shouldPoll ist binnen 1,5 s nach der letzten Abfrage false', () => {
|
||||
const { store, clock } = makeStore();
|
||||
const a = store.create('t1', 'u1', 'http://c.example', 'tok');
|
||||
expect(store.shouldPoll(a)).toBe(true);
|
||||
store.markPolled(a);
|
||||
clock.t += 1000;
|
||||
expect(store.shouldPoll(a)).toBe(false);
|
||||
clock.t += 500;
|
||||
expect(store.shouldPoll(a)).toBe(true);
|
||||
});
|
||||
|
||||
it('clearTenant verwirft nur die Ablaeufe dieser Organisation', () => {
|
||||
const { store } = makeStore();
|
||||
const a = store.create('t1', 'u1', 'http://c.example', 'tok');
|
||||
const b = store.create('t2', 'u2', 'http://c.example', 'tok');
|
||||
store.clearTenant('t1');
|
||||
expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined();
|
||||
expect(store.get(b.flowId, 't2', 'u2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user