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,317 @@
import { Readable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import {
authFailureToException,
getAppPassword,
getCurrentUser,
ocsRequest,
pollLoginFlow,
revokeAppPassword,
startLoginFlow,
} from './nextcloud-auth-client';
import { NextcloudCallGate } from './nextcloud-call-gate';
import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http';
const BASE = 'http://cloud.example';
const TOKEN128 = 'A1b2C3d4'.repeat(16); // 128 alphanumerische Zeichen
function reply(
statusCode: number,
body: unknown = '',
headers: Record<string, string> = {},
): NcTransportResponse {
const text = typeof body === 'string' ? body : JSON.stringify(body);
return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) };
}
function fake(handler: (req: NcTransportRequest) => NcTransportResponse) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
return handler(req);
};
return { transport, calls };
}
const ocs = (data: unknown) => ({ ocs: { meta: { status: 'ok' }, data } });
describe('getAppPassword', () => {
it('sendet genau GET getapppassword mit Basic-Anmeldung und OCS-Kopfzeilen', async () => {
const { transport, calls } = fake(() => reply(200, ocs({ apppassword: 'app-pw-123' })));
const gate = new NextcloudCallGate();
const res = await getAppPassword(transport, gate, BASE, 'anna', 'geheim');
expect(res).toEqual({ ok: true, appPassword: 'app-pw-123' });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/getapppassword');
expect(calls[0].headers).toEqual({
'user-agent': 'Tessera (Nextcloud-Dateien)',
'ocs-apirequest': 'true',
accept: 'application/json',
authorization: 'Basic YW5uYTpnZWhlaW0=',
});
});
it('401: Art credentials, die Sperre markiert nichts', async () => {
const { transport } = fake(() => reply(401));
const gate = new NextcloudCallGate();
const res = await getAppPassword(transport, gate, BASE, 'anna', 'falsch');
expect(res).toMatchObject({ ok: false, kind: 'credentials' });
expect(gate.isPaused('http://cloud.example').paused).toBe(false);
});
it('403: app-password-given', async () => {
const { transport } = fake(() => reply(403));
expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({
ok: false,
kind: 'app-password-given',
});
});
it('429: locked, und die Sperre haelt den Ursprung an (naechster Aufruf ohne Transport)', async () => {
const { transport, calls } = fake(() => reply(429, '', { 'retry-after': '600' }));
const gate = new NextcloudCallGate();
const first = await getAppPassword(transport, gate, BASE, 'a', 'b');
expect(first).toMatchObject({ ok: false, kind: 'locked', retryAfterSeconds: 600 });
const second = await getAppPassword(transport, gate, BASE, 'a', 'b');
expect(second).toMatchObject({ ok: false, kind: 'locked' });
expect(calls).toHaveLength(1);
});
it('503 ist Wartungsmodus, Antwort ohne apppassword ist invalid-response', async () => {
expect(
await getAppPassword(
fake(() => reply(503)).transport,
new NextcloudCallGate(),
BASE,
'a',
'b',
),
).toMatchObject({ ok: false, kind: 'maintenance' });
expect(
await getAppPassword(
fake(() => reply(200, ocs({}))).transport,
new NextcloudCallGate(),
BASE,
'a',
'b',
),
).toMatchObject({ ok: false, kind: 'invalid-response' });
});
it('eine Weiterleitung wird nicht befolgt: redirect', async () => {
const { transport, calls } = fake(() => reply(302, '', { location: 'http://evil.example/' }));
expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({
ok: false,
kind: 'redirect',
});
expect(calls).toHaveLength(1);
});
});
describe('getCurrentUser', () => {
it('liest id und display-name mit dem App-Passwort', async () => {
const { transport, calls } = fake(() =>
reply(200, ocs({ id: 'anna', 'display-name': 'Anna Müller' })),
);
const res = await getCurrentUser(
transport,
new NextcloudCallGate(),
BASE,
'anna',
'app-pw-123',
);
expect(res).toEqual({ ok: true, id: 'anna', displayName: 'Anna Müller' });
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/cloud/user');
expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz');
});
it('faellt auf displayname zurueck und erlaubt fehlenden Namen', async () => {
const a = await getCurrentUser(
fake(() => reply(200, ocs({ id: 'anna', displayname: 'Anna' }))).transport,
new NextcloudCallGate(),
BASE,
'anna',
'x',
);
expect(a).toEqual({ ok: true, id: 'anna', displayName: 'Anna' });
const b = await getCurrentUser(
fake(() => reply(200, ocs({ id: 'anna' }))).transport,
new NextcloudCallGate(),
BASE,
'anna',
'x',
);
expect(b).toEqual({ ok: true, id: 'anna', displayName: null });
});
it('ohne id: invalid-response', async () => {
const res = await getCurrentUser(
fake(() => reply(200, ocs({}))).transport,
new NextcloudCallGate(),
BASE,
'anna',
'x',
);
expect(res).toMatchObject({ ok: false, kind: 'invalid-response' });
});
});
describe('revokeAppPassword', () => {
it('sendet DELETE apppassword mit dem App-Passwort', async () => {
const { transport, calls } = fake(() => reply(200, ocs([])));
const res = await revokeAppPassword(
transport,
new NextcloudCallGate(),
BASE,
'anna',
'app-pw-123',
);
expect(res).toEqual({ ok: true });
expect(calls[0].method).toBe('DELETE');
expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword');
expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz');
expect(calls[0].headersTimeoutMs).toBe(10_000);
});
it('ein toter Zugangsschluessel geht gar nicht erst raus', async () => {
const gate = new NextcloudCallGate();
gate.markDead('key-1');
const { transport, calls } = fake(() => reply(200, ocs([])));
const res = await revokeAppPassword(transport, gate, BASE, 'anna', 'x', 'key-1');
expect(res).toMatchObject({ ok: false, kind: 'credential-dead' });
expect(calls).toHaveLength(0);
});
});
describe('startLoginFlow', () => {
it('baut den Link aus Basis und Token neu und verwirft poll.endpoint und fremden Ursprung', async () => {
const { transport, calls } = fake(() =>
reply(200, {
poll: { token: TOKEN128, endpoint: 'http://evil.example/login/v2/poll' },
login: `http://evil.example/login/v2/flow/${TOKEN128}`,
}),
);
const res = await startLoginFlow(transport, new NextcloudCallGate(), BASE);
expect(res).toEqual({
ok: true,
loginUrl: `http://cloud.example/index.php/login/v2/flow/${TOKEN128}`,
pollToken: TOKEN128,
});
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2');
});
it('login ohne passendes Token: invalid-response', async () => {
const { transport } = fake(() =>
reply(200, {
poll: { token: TOKEN128, endpoint: 'x' },
login: 'http://cloud.example/anderswo',
}),
);
expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({
ok: false,
kind: 'invalid-response',
});
});
it('ein unbrauchbares Abfrage-Token wird abgelehnt', async () => {
const { transport } = fake(() =>
reply(200, {
poll: { token: 'kurz', endpoint: 'x' },
login: `http://cloud.example/login/v2/flow/${TOKEN128}`,
}),
);
expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({
ok: false,
kind: 'invalid-response',
});
});
});
describe('pollLoginFlow', () => {
it('fragt nur {Basis}/index.php/login/v2/poll mit token=... ab', async () => {
const { transport, calls } = fake(() => reply(404));
const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128);
expect(res).toEqual({ ok: true, state: 'pending' });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2/poll');
expect(calls[0].body).toBe(`token=${TOKEN128}`);
expect(calls[0].headers['content-type']).toBe('application/x-www-form-urlencoded');
});
it('200: granted mit loginName und appPassword, server wird ignoriert; evil.example nie angefragt', async () => {
const { transport, calls } = fake(() =>
reply(200, { server: 'http://evil.example', loginName: 'zoe', appPassword: 'x' }),
);
const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128);
expect(res).toEqual({ ok: true, state: 'granted', loginName: 'zoe', appPassword: 'x' });
expect(calls.every((c) => !c.url.includes('evil.example'))).toBe(true);
});
it('200 ohne Zugangsdaten: invalid-response; 429: locked', async () => {
expect(
await pollLoginFlow(
fake(() => reply(200, {})).transport,
new NextcloudCallGate(),
BASE,
TOKEN128,
),
).toMatchObject({ ok: false, kind: 'invalid-response' });
expect(
await pollLoginFlow(
fake(() => reply(429)).transport,
new NextcloudCallGate(),
BASE,
TOKEN128,
),
).toMatchObject({ ok: false, kind: 'locked' });
});
});
describe('Fehlerabbildung', () => {
const body = (kind: Parameters<typeof authFailureToException>[0]['kind']) =>
authFailureToException({ ok: false, kind, retryAfterSeconds: 42 });
it('antwortet nie mit 401 oder 403', () => {
for (const kind of [
'credentials',
'app-password-given',
'locked',
'maintenance',
'redirect',
'timeout',
'network',
'tls',
'invalid-response',
'credential-dead',
'upstream',
] as const) {
const status = body(kind).getStatus();
expect([401, 403], kind).not.toContain(status);
}
});
it('locked traegt retryAfterSeconds', () => {
expect(body('locked').getResponse()).toMatchObject({
code: 'nextcloudLocked',
retryAfterSeconds: 42,
});
expect(body('locked').getStatus()).toBe(503);
});
});
describe('ocsRequest (allgemein)', () => {
it('liefert ocs.data bei 2xx', async () => {
const res = await ocsRequest(
fake(() => reply(200, ocs({ hallo: 'welt' }))).transport,
new NextcloudCallGate(),
BASE,
{ method: 'GET', segments: ['cloud', 'capabilities'], authorization: 'Basic eDp5' },
);
expect(res).toEqual({ ok: true, status: 200, data: { hallo: 'welt' } });
});
});