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:
2026-10-08 18:02:45 +02:00
parent 960696745f
commit d00b6ff79f
25 changed files with 3533 additions and 55 deletions
@@ -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();
});
});