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,18 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Anmeldung mit Nextcloud-Benutzername und Passwort (quick-261008-mzu). Das
|
||||
* Passwort wird nur einmal an Nextcloud gesendet und nie gespeichert, geloggt
|
||||
* oder zurueckgegeben.
|
||||
*/
|
||||
export class ConnectPasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
loginName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
password!: string;
|
||||
}
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import type { HttpException } from '@nestjs/common';
|
||||
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
|
||||
import type { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { ncErrorDefault } from './nextcloud-files.types';
|
||||
import {
|
||||
basicAuth,
|
||||
type NcResult,
|
||||
type NextcloudTransport,
|
||||
ncRequest,
|
||||
readCappedText,
|
||||
} from './nextcloud-http';
|
||||
|
||||
/**
|
||||
* Anmelde-Client des Moduls "Nextcloud-Dateien" (quick-261008-mzu): alles, was
|
||||
* Zugangsdaten ausstellt, prueft oder widerruft. Jeder Aufruf geht durch
|
||||
* `ncRequest` und damit durch die Aufrufsperre.
|
||||
*
|
||||
* Was hier gilt und warum:
|
||||
* - Ein 401 auf `getapppassword` ist DOPPELDEUTIG: falsches Passwort ODER ein
|
||||
* Konto mit Zwei-Faktor-Anmeldung (gemessen: Nextcloud lehnt solche Konten
|
||||
* ab, bevor das Passwort geprueft wird). Der Aufrufer fragt deshalb mit
|
||||
* `credentialsOrTwoFactor` nach und bietet die Browser-Anmeldung an.
|
||||
* - Ein 429 wird NIE wiederholt. Die Aufrufsperre in `ncRequest` haelt den
|
||||
* ganzen Ursprung an; hier wird es nur als `locked` gemeldet.
|
||||
* - Mit einem App-Passwort laesst sich kein weiteres App-Passwort holen
|
||||
* (403) — daran erkennt man einen Zugang, der nicht per Passwort geht.
|
||||
* - `poll.endpoint` und der Ursprung von `login` aus der Antwort des
|
||||
* Login Flow v2 werden VERWORFEN: Nextcloud baut sie aus dem Host-Header
|
||||
* der Anfrage, ein falscher Wert waere eine Weiterleitung an einen
|
||||
* fremden Host (SSRF). Abgefragt wird immer
|
||||
* `{Basis}/index.php/login/v2/poll`; der Link fuer den Benutzer wird aus
|
||||
* der Basis und dem Token der Antwort neu gebaut.
|
||||
* - Passwoerter und App-Passwoerter stehen nur im `Authorization`-Wert eines
|
||||
* einzelnen Aufrufs; nichts davon wird geloggt oder in Fehlern genannt.
|
||||
*/
|
||||
|
||||
/** Ergebnis ohne Erfolg; Art `credentials`/`app-password-given`/`locked` sind Anmelde-spezifisch. */
|
||||
export type AuthFailureKind =
|
||||
| 'credentials'
|
||||
| 'app-password-given'
|
||||
| 'locked'
|
||||
| 'maintenance'
|
||||
| 'redirect'
|
||||
| 'timeout'
|
||||
| 'network'
|
||||
| 'tls'
|
||||
| 'invalid-response'
|
||||
| 'credential-dead'
|
||||
| 'upstream';
|
||||
|
||||
export interface AuthFailure {
|
||||
ok: false;
|
||||
kind: AuthFailureKind;
|
||||
status?: number;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
const OCS_MAX_BYTES = 1024 * 1024;
|
||||
const SMALL_MAX_BYTES = 256 * 1024;
|
||||
const REVOKE_TIMEOUT_MS = 10_000;
|
||||
const FLOW_TOKEN_RE = /^[A-Za-z0-9]{32,256}$/;
|
||||
const FLOW_LOGIN_RE = /login\/v2\/flow\/([A-Za-z0-9]{32,256})$/;
|
||||
|
||||
/** Ordnet ein Fehlergebnis der Transportschicht einer Anmelde-Fehlerart zu. */
|
||||
export function toAuthFailure(result: Extract<NcResult, { ok: false }>): AuthFailure {
|
||||
switch (result.kind) {
|
||||
case 'paused':
|
||||
return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds };
|
||||
case 'http':
|
||||
if (result.status === 429) {
|
||||
return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds };
|
||||
}
|
||||
return { ok: false, kind: 'upstream', status: result.status };
|
||||
case 'redirect':
|
||||
return { ok: false, kind: 'redirect' };
|
||||
case 'timeout':
|
||||
return { ok: false, kind: 'timeout' };
|
||||
case 'tls':
|
||||
return { ok: false, kind: 'tls' };
|
||||
case 'credential-dead':
|
||||
return { ok: false, kind: 'credential-dead' };
|
||||
case 'too-large':
|
||||
case 'invalid-response':
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
default:
|
||||
return { ok: false, kind: 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt einen Anmelde-Fehler in die Fehlerantwort der API (D-D, nie 401/403). */
|
||||
export function authFailureToException(failure: AuthFailure): HttpException {
|
||||
switch (failure.kind) {
|
||||
case 'locked':
|
||||
return ncErrorDefault('nextcloudLocked', {
|
||||
retryAfterSeconds: failure.retryAfterSeconds ?? 900,
|
||||
});
|
||||
case 'maintenance':
|
||||
return ncErrorDefault('nextcloudMaintenance');
|
||||
case 'redirect':
|
||||
return ncErrorDefault('nextcloudRedirect');
|
||||
case 'timeout':
|
||||
case 'network':
|
||||
case 'tls':
|
||||
return ncErrorDefault('nextcloudUnavailable');
|
||||
case 'credential-dead':
|
||||
return ncErrorDefault('connectionExpired');
|
||||
case 'credentials':
|
||||
return ncErrorDefault('credentialsOrTwoFactor');
|
||||
case 'app-password-given':
|
||||
return ncErrorDefault('useBrowserLogin');
|
||||
default:
|
||||
return ncErrorDefault('nextcloudError');
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehler-Code zu einem Anmelde-Fehler (fuer `{ state: 'failed', code }`). */
|
||||
export function authFailureCode(failure: AuthFailure): string {
|
||||
const body = authFailureToException(failure).getResponse() as { code: string };
|
||||
return body.code;
|
||||
}
|
||||
|
||||
interface OcsOk {
|
||||
ok: true;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface OcsOptions {
|
||||
method: string;
|
||||
segments: readonly string[];
|
||||
authorization: string;
|
||||
credentialKey?: string;
|
||||
headersTimeoutMs?: number;
|
||||
bodyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allgemeiner OCS-Aufruf (Etappe 2 nutzt ihn fuer Freigaben). Liefert den
|
||||
* entpackten `ocs.data`-Teil bei 2xx, sonst einen Fehler. 401 und 403 werden
|
||||
* als `credentials` / `app-password-given` gemeldet; 503 als `maintenance`.
|
||||
*/
|
||||
export async function ocsRequest(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
opts: OcsOptions,
|
||||
): Promise<OcsOk | AuthFailure> {
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl,
|
||||
prefix: '/ocs/v2.php/',
|
||||
segments: opts.segments,
|
||||
method: opts.method,
|
||||
authorization: opts.authorization,
|
||||
credentialKey: opts.credentialKey,
|
||||
ocs: true,
|
||||
headersTimeoutMs: opts.headersTimeoutMs,
|
||||
bodyTimeoutMs: opts.bodyTimeoutMs,
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
|
||||
if (res.status === 401) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'credentials', status: 401 };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'app-password-given', status: 403 };
|
||||
}
|
||||
if (res.status === 503) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'maintenance', status: 503 };
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, OCS_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as { ocs?: { data?: unknown } };
|
||||
return { ok: true, status: res.status, data: parsed?.ocs?.data ?? null };
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(body: Parameters<typeof readCappedText>[0]): Promise<void> {
|
||||
await readCappedText(body, SMALL_MAX_BYTES);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest mit Benutzername und echtem Passwort EINEN App-Passwort-Zugang aus.
|
||||
* Das echte Passwort lebt nur in diesem Aufruf.
|
||||
*/
|
||||
export async function getAppPassword(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
password: string,
|
||||
): Promise<{ ok: true; appPassword: string } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'GET',
|
||||
segments: ['core', 'getapppassword'],
|
||||
authorization: basicAuth(loginName, password),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
const appPassword = asString((res.data as { apppassword?: unknown } | null)?.apppassword);
|
||||
if (!appPassword) return { ok: false, kind: 'invalid-response' };
|
||||
return { ok: true, appPassword };
|
||||
}
|
||||
|
||||
/** Wer ist das? `id` ist die Nextcloud-Kennung fuer die Dateipfade (nicht der Anmeldename). */
|
||||
export async function getCurrentUser(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<{ ok: true; id: string; displayName: string | null } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'GET',
|
||||
segments: ['cloud', 'user'],
|
||||
authorization: basicAuth(loginName, appPassword),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
const data = (res.data ?? {}) as Record<string, unknown>;
|
||||
const id = asString(data.id);
|
||||
if (!id || id.length > 256) return { ok: false, kind: 'invalid-response' };
|
||||
const displayName = asString(data['display-name']) ?? asString(data.displayname);
|
||||
return { ok: true, id, displayName: displayName ? displayName.slice(0, 256) : null };
|
||||
}
|
||||
|
||||
/** Widerruft den App-Passwort-Zugang, mit dem der Aufruf selbst angemeldet ist. */
|
||||
export async function revokeAppPassword(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
credentialKey?: string,
|
||||
): Promise<{ ok: true } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'DELETE',
|
||||
segments: ['core', 'apppassword'],
|
||||
authorization: basicAuth(loginName, appPassword),
|
||||
credentialKey,
|
||||
headersTimeoutMs: REVOKE_TIMEOUT_MS,
|
||||
bodyTimeoutMs: REVOKE_TIMEOUT_MS,
|
||||
});
|
||||
return res.ok ? { ok: true } : res;
|
||||
}
|
||||
|
||||
/** Startet den Login Flow v2: Link fuer den Benutzer (neu gebaut) und Abfrage-Token (nur Server). */
|
||||
export async function startLoginFlow(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
): Promise<{ ok: true; loginUrl: string; pollToken: string } | AuthFailure> {
|
||||
const base = normalizeCloudUrl(baseUrl);
|
||||
if (base === null) return { ok: false, kind: 'invalid-response' };
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl: base,
|
||||
prefix: '/index.php/login/v2',
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return res.status === 503
|
||||
? { ok: false, kind: 'maintenance', status: 503 }
|
||||
: { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, SMALL_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as {
|
||||
poll?: { token?: unknown };
|
||||
login?: unknown;
|
||||
};
|
||||
const pollToken = parsed?.poll?.token;
|
||||
const login = parsed?.login;
|
||||
if (typeof pollToken !== 'string' || !FLOW_TOKEN_RE.test(pollToken)) {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
if (typeof login !== 'string') return { ok: false, kind: 'invalid-response' };
|
||||
const match = FLOW_LOGIN_RE.exec(login);
|
||||
if (!match) return { ok: false, kind: 'invalid-response' };
|
||||
return {
|
||||
ok: true,
|
||||
loginUrl: `${base}/index.php/login/v2/flow/${match[1]}`,
|
||||
pollToken,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { ok: true; state: 'pending' }
|
||||
| { ok: true; state: 'granted'; loginName: string; appPassword: string };
|
||||
|
||||
/**
|
||||
* Fragt die Browser-Anmeldung ab — IMMER `{Basis}/index.php/login/v2/poll`, nie
|
||||
* die `poll.endpoint` aus der Antwort. 404 heisst: noch nicht bestaetigt.
|
||||
*/
|
||||
export async function pollLoginFlow(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
pollToken: string,
|
||||
): Promise<PollResult | AuthFailure> {
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl,
|
||||
prefix: '/index.php/login/v2/poll',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: `token=${encodeURIComponent(pollToken)}`,
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
if (res.status === 404) {
|
||||
await drain(res.body);
|
||||
return { ok: true, state: 'pending' };
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return res.status === 503
|
||||
? { ok: false, kind: 'maintenance', status: 503 }
|
||||
: { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, SMALL_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as { loginName?: unknown; appPassword?: unknown };
|
||||
const loginName = asString(parsed?.loginName);
|
||||
const appPassword = asString(parsed?.appPassword);
|
||||
if (!loginName || !appPassword || loginName.length > 256 || appPassword.length > 512) {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
// `server` aus der Antwort wird ignoriert (siehe Kopfkommentar).
|
||||
return { ok: true, state: 'granted', loginName, appPassword };
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((db: any, tenantId: string, userId?: string) => db.__bound(tenantId, userId)),
|
||||
}));
|
||||
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { credentialKeyOf, NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http';
|
||||
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
|
||||
const BASE = 'http://cloud.example';
|
||||
const TOKEN128 = 'A1b2C3d4'.repeat(16);
|
||||
|
||||
// Wertetabelle der Literale: anna:geheim, anna:app-pw-123, anna:old-pw
|
||||
const AUTH_PASSWORD = 'Basic YW5uYTpnZWhlaW0=';
|
||||
const AUTH_APP = 'Basic YW5uYTphcHAtcHctMTIz';
|
||||
const AUTH_OLD = 'Basic YW5uYTpvbGQtcHc=';
|
||||
// anna@example.com:app-pw-123
|
||||
const AUTH_EMAIL_APP = 'Basic YW5uYUBleGFtcGxlLmNvbTphcHAtcHctMTIz';
|
||||
|
||||
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)]) };
|
||||
}
|
||||
const ocs = (data: unknown) => ({ ocs: { meta: { status: 'ok' }, data } });
|
||||
|
||||
interface Row {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
ncUserId: string;
|
||||
ncLoginName: string | null;
|
||||
ncDisplayName: string | null;
|
||||
encryptedAppPassword: string;
|
||||
status: 'ACTIVE' | 'EXPIRED';
|
||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function makeRow(over: Partial<Row> = {}): Row {
|
||||
return {
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
baseUrl: BASE,
|
||||
ncUserId: 'anna',
|
||||
ncLoginName: 'anna',
|
||||
ncDisplayName: 'Anna Müller',
|
||||
encryptedAppPassword: 'enc(app-pw-123)',
|
||||
status: 'ACTIVE',
|
||||
connectedVia: 'PASSWORD',
|
||||
createdAt: new Date('2026-10-01T10:00:00Z'),
|
||||
updatedAt: new Date('2026-10-02T10:00:00Z'),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
rows?: Row[];
|
||||
base?: string | null;
|
||||
upsertFails?: boolean;
|
||||
decryptFails?: boolean;
|
||||
handler?: (req: NcTransportRequest) => NcTransportResponse;
|
||||
}
|
||||
|
||||
function setup(opts: Setup = {}) {
|
||||
const rows: Row[] = opts.rows ? [...opts.rows] : [];
|
||||
const bound: { tenantId: string; userId?: string }[] = [];
|
||||
const dbCalls: { op: string; args: any }[] = [];
|
||||
const matches = (r: Row, where: any) =>
|
||||
(where.tenantId === undefined || r.tenantId === where.tenantId) &&
|
||||
(where.userId === undefined || r.userId === where.userId) &&
|
||||
(where.status === undefined || r.status === where.status);
|
||||
const db = {
|
||||
__bound: (tenantId: string, userId?: string) => {
|
||||
bound.push({ tenantId, userId });
|
||||
return {
|
||||
nextcloudFilesAccount: {
|
||||
findFirst: vi.fn(async (args: any) => {
|
||||
dbCalls.push({ op: 'findFirst', args });
|
||||
return rows.find((r) => matches(r, args.where)) ?? null;
|
||||
}),
|
||||
upsert: vi.fn(async (args: any) => {
|
||||
dbCalls.push({ op: 'upsert', args });
|
||||
if (opts.upsertFails) throw new Error('db down');
|
||||
const key = args.where.tenantId_userId;
|
||||
const i = rows.findIndex((r) => r.tenantId === key.tenantId && r.userId === key.userId);
|
||||
if (i >= 0) rows[i] = { ...rows[i], ...args.update, updatedAt: new Date() };
|
||||
else rows.push({ ...makeRow(), ...args.create, updatedAt: new Date() });
|
||||
return rows[i >= 0 ? i : rows.length - 1];
|
||||
}),
|
||||
deleteMany: vi.fn(async (args: any) => {
|
||||
dbCalls.push({ op: 'deleteMany', args });
|
||||
for (let i = rows.length - 1; i >= 0; i--)
|
||||
if (matches(rows[i], args.where)) rows.splice(i, 1);
|
||||
return { count: 1 };
|
||||
}),
|
||||
updateMany: vi.fn(async (args: any) => {
|
||||
dbCalls.push({ op: 'updateMany', args });
|
||||
for (const r of rows) if (matches(r, args.where)) Object.assign(r, args.data);
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const base = opts.base === undefined ? BASE : opts.base;
|
||||
const listeners: ((t: string) => void)[] = [];
|
||||
const settings = {
|
||||
getBaseUrl: vi.fn(async () => base),
|
||||
getStatus: vi.fn(async () => ({
|
||||
configured: base !== null,
|
||||
serverUrl: base,
|
||||
host: base ? new URL(base).host : null,
|
||||
account: null,
|
||||
})),
|
||||
onAddressChange: (l: (t: string) => void) => listeners.push(l),
|
||||
};
|
||||
const crypto = {
|
||||
encrypt: vi.fn((p: string) => `enc(${p})`),
|
||||
decrypt: vi.fn((e: string) => {
|
||||
if (opts.decryptFails) throw new Error('bad cipher');
|
||||
const m = /^enc\((.*)\)$/.exec(e);
|
||||
if (!m) throw new Error('bad cipher');
|
||||
return m[1];
|
||||
}),
|
||||
};
|
||||
const calls: NcTransportRequest[] = [];
|
||||
const transport: NextcloudTransport = async (req) => {
|
||||
calls.push(req);
|
||||
return (opts.handler ?? (() => reply(500)))(req);
|
||||
};
|
||||
const gate = new NextcloudCallGate();
|
||||
const guard = new NextcloudLoginGuard();
|
||||
const flows = new LoginFlowStore();
|
||||
const service = new NextcloudFilesAccountService(
|
||||
db as any,
|
||||
crypto as any,
|
||||
settings as any,
|
||||
guard,
|
||||
flows,
|
||||
gate,
|
||||
transport,
|
||||
);
|
||||
return { service, rows, bound, dbCalls, settings, crypto, calls, gate, guard, flows, listeners };
|
||||
}
|
||||
|
||||
/** Typische Nextcloud-Antworten fuer anna. */
|
||||
function happy(req: NcTransportRequest): NcTransportResponse {
|
||||
if (req.url.endsWith('/core/getapppassword'))
|
||||
return reply(200, ocs({ apppassword: 'app-pw-123' }));
|
||||
if (req.url.endsWith('/cloud/user'))
|
||||
return reply(200, ocs({ id: 'anna', 'display-name': 'Anna Müller' }));
|
||||
if (req.url.endsWith('/core/apppassword') && req.method === 'DELETE') return reply(200, ocs([]));
|
||||
return reply(500);
|
||||
}
|
||||
|
||||
const errOf = async (p: Promise<unknown>) => {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
return { status: (e as any).getStatus?.() as number, body: (e as any).getResponse?.() as any };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('Verbinden mit Passwort', () => {
|
||||
it('zwei Aufrufe (getapppassword, cloud/user), nur das verschluesselte App-Passwort wird gespeichert', async () => {
|
||||
const s = setup({ handler: happy });
|
||||
const result = await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim');
|
||||
|
||||
expect(s.calls).toHaveLength(2);
|
||||
expect(s.calls[0].method).toBe('GET');
|
||||
expect(s.calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/getapppassword');
|
||||
expect(s.calls[0].headers.authorization).toBe(AUTH_PASSWORD);
|
||||
expect(s.calls[1].url).toBe('http://cloud.example/ocs/v2.php/cloud/user');
|
||||
expect(s.calls[1].headers.authorization).toBe(AUTH_APP);
|
||||
|
||||
const upsert = s.dbCalls.find((c) => c.op === 'upsert')!;
|
||||
expect(upsert.args.create).toMatchObject({
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
baseUrl: BASE,
|
||||
ncUserId: 'anna',
|
||||
ncLoginName: 'anna',
|
||||
ncDisplayName: 'Anna Müller',
|
||||
encryptedAppPassword: 'enc(app-pw-123)',
|
||||
connectedVia: 'PASSWORD',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
// Weder Antwort noch irgendein Aufrufargument ausser der Upsert-Nutzlast tragen ein Geheimnis.
|
||||
const everythingElse = JSON.stringify([result, s.dbCalls.filter((c) => c.op !== 'upsert')]);
|
||||
expect(everythingElse).not.toContain('geheim');
|
||||
expect(everythingElse).not.toContain('app-pw-123');
|
||||
expect(JSON.stringify(upsert.args)).not.toContain('geheim');
|
||||
expect(s.crypto.encrypt).toHaveBeenCalledTimes(1);
|
||||
expect(s.crypto.encrypt).toHaveBeenCalledWith('app-pw-123');
|
||||
expect(result.account).toMatchObject({ connected: true, ncUserId: 'anna', status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('Nextcloud 401: 422 credentialsOrTwoFactor und ein gezaehlter Fehlversuch; der 4. Versuch ist 429 ohne Aufruf', async () => {
|
||||
const s = setup({ handler: () => reply(401) });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x'));
|
||||
expect(err?.status).toBe(422);
|
||||
expect(err?.body.code).toBe('credentialsOrTwoFactor');
|
||||
}
|
||||
expect(s.calls).toHaveLength(3);
|
||||
const blocked = await errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x'));
|
||||
expect(blocked?.status).toBe(429);
|
||||
expect(blocked?.body.code).toBe('tooManyAttempts');
|
||||
expect(blocked?.body.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(s.calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Nextcloud 403 auf getapppassword: 422 useBrowserLogin, kein Fehlversuch', async () => {
|
||||
const s = setup({ handler: () => reply(403) });
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x'));
|
||||
expect(err?.status).toBe(422);
|
||||
expect(err?.body.code).toBe('useBrowserLogin');
|
||||
expect(() => s.guard.checkPasswordAttempt('u1', BASE)).not.toThrow();
|
||||
});
|
||||
|
||||
it('Nextcloud 429: 503 nextcloudLocked; der naechste Versuch (anderer Benutzer) erreicht Nextcloud nicht mehr', async () => {
|
||||
const s = setup({ handler: () => reply(429, '', { 'retry-after': '900' }) });
|
||||
const first = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x'));
|
||||
expect(first?.status).toBe(503);
|
||||
expect(first?.body.code).toBe('nextcloudLocked');
|
||||
expect(first?.body.retryAfterSeconds).toBe(900);
|
||||
const second = await errOf(s.service.connectWithPassword('t1', 'u2', 'bert', 'y'));
|
||||
expect(second?.status).toBe(503);
|
||||
expect(second?.body.code).toBe('nextcloudLocked');
|
||||
expect(s.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nicht eingerichtet: 409 notConfigured ohne Aufruf', async () => {
|
||||
const s = setup({ base: null, handler: happy });
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'));
|
||||
expect(err?.status).toBe(409);
|
||||
expect(err?.body.code).toBe('notConfigured');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keine Antwort mit 401 oder 403 fuer Nextcloud-Fehler', async () => {
|
||||
for (const status of [404, 500, 503]) {
|
||||
const s = setup({ handler: () => reply(status) });
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x'));
|
||||
expect([401, 403]).not.toContain(err?.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('App-Passwort-Hygiene (D-P)', () => {
|
||||
it('cloud/user scheitert nach erfolgreichem getapppassword: genau ein Widerruf, kein Upsert', async () => {
|
||||
const s = setup({
|
||||
handler: (req) => {
|
||||
if (req.url.endsWith('/core/getapppassword'))
|
||||
return reply(200, ocs({ apppassword: 'app-pw-123' }));
|
||||
if (req.url.endsWith('/cloud/user')) return reply(500);
|
||||
if (req.method === 'DELETE') return reply(200, ocs([]));
|
||||
return reply(500);
|
||||
},
|
||||
});
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'));
|
||||
expect(err).not.toBeNull();
|
||||
const deletes = s.calls.filter((c) => c.method === 'DELETE');
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(deletes[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword');
|
||||
expect(deletes[0].headers.authorization).toBe(AUTH_APP);
|
||||
expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false);
|
||||
});
|
||||
|
||||
it('Upsert wirft: derselbe Widerruf, Fehler wird weitergegeben', async () => {
|
||||
const s = setup({ handler: happy, upsertFails: true });
|
||||
const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'));
|
||||
expect(err).not.toBeNull();
|
||||
const deletes = s.calls.filter((c) => c.method === 'DELETE');
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(deletes[0].headers.authorization).toBe(AUTH_APP);
|
||||
});
|
||||
|
||||
it('erneutes Verbinden: das alte App-Passwort derselben Adresse wird VOR dem Upsert widerrufen', async () => {
|
||||
const order: string[] = [];
|
||||
const s = setup({
|
||||
rows: [makeRow({ encryptedAppPassword: 'enc(old-pw)' })],
|
||||
handler: (req) => {
|
||||
if (req.method === 'DELETE') order.push(`delete:${req.headers.authorization}`);
|
||||
return happy(req);
|
||||
},
|
||||
});
|
||||
s.crypto.encrypt.mockImplementation((p: string) => {
|
||||
order.push('encrypt');
|
||||
return `enc(${p})`;
|
||||
});
|
||||
await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim');
|
||||
expect(order[0]).toBe(`delete:${AUTH_OLD}`);
|
||||
expect(order.indexOf('encrypt')).toBeGreaterThan(0);
|
||||
expect(s.rows[0].encryptedAppPassword).toBe('enc(app-pw-123)');
|
||||
// Genau ein Widerruf (das alte), das frische bleibt bestehen.
|
||||
expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Zeile fuer eine andere Adresse: kein Widerruf an irgendeinen Host, Zeile wird ueberschrieben', async () => {
|
||||
const s = setup({
|
||||
rows: [makeRow({ baseUrl: 'http://alt.example', encryptedAppPassword: 'enc(old-pw)' })],
|
||||
handler: happy,
|
||||
});
|
||||
await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim');
|
||||
expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0);
|
||||
expect(s.calls.every((c) => c.url.startsWith('http://cloud.example/'))).toBe(true);
|
||||
expect(s.rows[0].baseUrl).toBe(BASE);
|
||||
expect(s.rows[0].encryptedAppPassword).toBe('enc(app-pw-123)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser-Anmeldung (Login Flow v2)', () => {
|
||||
const flowHandler =
|
||||
(extra?: (req: NcTransportRequest) => NcTransportResponse | undefined) =>
|
||||
(req: NcTransportRequest) => {
|
||||
const custom = extra?.(req);
|
||||
if (custom) return custom;
|
||||
if (req.url === 'http://cloud.example/index.php/login/v2') {
|
||||
return reply(200, {
|
||||
poll: { token: TOKEN128, endpoint: 'http://evil.example/poll' },
|
||||
login: `http://evil.example/login/v2/flow/${TOKEN128}`,
|
||||
});
|
||||
}
|
||||
if (req.url === 'http://cloud.example/index.php/login/v2/poll') return reply(404);
|
||||
return happy(req);
|
||||
};
|
||||
|
||||
it('startFlow liefert flowId, Link und Ablauf, aber nie das Abfrage-Token', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const res = await s.service.startFlow('t1', 'u1');
|
||||
expect(res.flowId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(res.loginUrl).toBe(`http://cloud.example/index.php/login/v2/flow/${TOKEN128}`);
|
||||
expect(new Date(res.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(JSON.stringify(res)).not.toContain('poll');
|
||||
// Das Abfrage-Token taucht im Link nicht als eigenes Feld auf; es steckt nur dort, wo es der Benutzer braucht.
|
||||
expect(Object.keys(res).sort()).toEqual(['expiresAt', 'flowId', 'loginUrl']);
|
||||
});
|
||||
|
||||
it('pollFlow: pending, dann granted -> verbunden mit LOGIN_FLOW, Ablauf entfernt', async () => {
|
||||
let granted = false;
|
||||
const s = setup({
|
||||
handler: flowHandler((req) => {
|
||||
if (req.url.endsWith('/login/v2/poll')) {
|
||||
return granted
|
||||
? reply(200, {
|
||||
server: 'http://evil.example',
|
||||
loginName: 'anna',
|
||||
appPassword: 'app-pw-123',
|
||||
})
|
||||
: reply(404);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'pending' });
|
||||
|
||||
granted = true;
|
||||
const entry = s.flows.get(flowId, 't1', 'u1')!;
|
||||
entry.lastPollAt = Number.NEGATIVE_INFINITY;
|
||||
expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'connected' });
|
||||
const upsert = s.dbCalls.find((c) => c.op === 'upsert')!;
|
||||
expect(upsert.args.create).toMatchObject({
|
||||
connectedVia: 'LOGIN_FLOW',
|
||||
encryptedAppPassword: 'enc(app-pw-123)',
|
||||
ncUserId: 'anna',
|
||||
});
|
||||
expect(s.flows.get(flowId, 't1', 'u1')).toBeUndefined();
|
||||
const polls = s.calls.filter((c) => c.url.endsWith('/login/v2/poll'));
|
||||
expect(polls.every((c) => c.url === 'http://cloud.example/index.php/login/v2/poll')).toBe(true);
|
||||
expect(s.calls.every((c) => !c.url.includes('evil.example'))).toBe(true);
|
||||
});
|
||||
|
||||
it('schnelle Browser-Abfragen (unter 1,5 s) erreichen Nextcloud nicht', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
await s.service.pollFlow('t1', 'u1', flowId);
|
||||
const before = s.calls.length;
|
||||
expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'pending' });
|
||||
expect(s.calls.length).toBe(before);
|
||||
});
|
||||
|
||||
it('granted, aber cloud/user scheitert: Widerruf des erteilten Passworts und failed', async () => {
|
||||
const s = setup({
|
||||
handler: flowHandler((req) => {
|
||||
if (req.url.endsWith('/login/v2/poll')) {
|
||||
return reply(200, { server: 'x', loginName: 'anna', appPassword: 'app-pw-123' });
|
||||
}
|
||||
if (req.url.endsWith('/cloud/user')) return reply(500);
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
const res = await s.service.pollFlow('t1', 'u1', flowId);
|
||||
expect(res.state).toBe('failed');
|
||||
const deletes = s.calls.filter((c) => c.method === 'DELETE');
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(deletes[0].headers.authorization).toBe(AUTH_APP);
|
||||
expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false);
|
||||
});
|
||||
|
||||
it('abgebrochener Ablauf, dessen Abfrage noch ein granted bringt: das Passwort wird widerrufen', async () => {
|
||||
let s!: ReturnType<typeof setup>;
|
||||
let flowId = '';
|
||||
s = setup({
|
||||
handler: flowHandler((req) => {
|
||||
if (req.url.endsWith('/login/v2/poll')) {
|
||||
// Waehrend die Abfrage unterwegs ist, bricht der Benutzer ab.
|
||||
void s.service.cancelFlow('t1', 'u1', flowId);
|
||||
return reply(200, { server: 'x', loginName: 'anna', appPassword: 'app-pw-123' });
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
flowId = (await s.service.startFlow('t1', 'u1')).flowId;
|
||||
const res = await s.service.pollFlow('t1', 'u1', flowId);
|
||||
expect(res.state).toBe('failed');
|
||||
expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(1);
|
||||
expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false);
|
||||
});
|
||||
|
||||
it('fremde Kennung: 404; abgelaufene: 410 flowExpired; Abbrechen entfernt', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
const foreign = await errOf(s.service.pollFlow('t1', 'u2', flowId));
|
||||
expect(foreign?.status).toBe(404);
|
||||
const foreignCancel = await errOf(s.service.cancelFlow('t1', 'u2', flowId));
|
||||
expect(foreignCancel?.status).toBe(404);
|
||||
expect(s.flows.get(flowId, 't1', 'u1')).toBeDefined();
|
||||
|
||||
s.flows.now = () => Date.now() + 21 * 60 * 1000;
|
||||
const expired = await errOf(s.service.pollFlow('t1', 'u1', flowId));
|
||||
expect(expired?.status).toBe(410);
|
||||
expect(expired?.body.code).toBe('flowExpired');
|
||||
});
|
||||
|
||||
it('cancelFlow entfernt den eigenen Ablauf', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
expect(await s.service.cancelFlow('t1', 'u1', flowId)).toEqual({ cancelled: true });
|
||||
expect((await errOf(s.service.pollFlow('t1', 'u1', flowId)))?.status).toBe(404);
|
||||
});
|
||||
|
||||
it('Adresswechsel: der Zuhoerer leert die Ablaeufe der Organisation', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
expect(s.listeners).toHaveLength(1);
|
||||
s.listeners[0]('t1');
|
||||
expect(s.flows.get(flowId, 't1', 'u1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Adresse seit dem Start geaendert: 410 flowExpired ohne Nextcloud-Aufruf', async () => {
|
||||
const s = setup({ handler: flowHandler() });
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
const before = s.calls.length;
|
||||
s.settings.getBaseUrl.mockResolvedValue('http://neu.example');
|
||||
const err = await errOf(s.service.pollFlow('t1', 'u1', flowId));
|
||||
expect(err?.status).toBe(410);
|
||||
expect(s.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trennen', () => {
|
||||
it('aktives Konto: Widerruf mit dem gespeicherten App-Passwort an die Adresse des Kontos, dann Zeile loeschen', async () => {
|
||||
const s = setup({ rows: [makeRow()], handler: happy });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.calls).toHaveLength(1);
|
||||
expect(s.calls[0].method).toBe('DELETE');
|
||||
expect(s.calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword');
|
||||
expect(s.calls[0].headers.authorization).toBe(AUTH_APP);
|
||||
expect(s.dbCalls.find((c) => c.op === 'deleteMany')?.args).toEqual({
|
||||
where: { tenantId: 't1', userId: 'u1' },
|
||||
});
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Widerruf scheitert (500): die Zeile wird trotzdem geloescht', async () => {
|
||||
const s = setup({ rows: [makeRow()], handler: () => reply(500) });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Widerruf laeuft in den Zeitablauf: die Zeile wird trotzdem geloescht', async () => {
|
||||
const s = setup({
|
||||
rows: [makeRow()],
|
||||
handler: () => {
|
||||
throw Object.assign(new Error('t'), { code: 'UND_ERR_HEADERS_TIMEOUT' });
|
||||
},
|
||||
});
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('andere Adresse als die aktuelle: gar kein Aufruf, Zeile geloescht', async () => {
|
||||
const s = setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })], handler: happy });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('abgelaufene Zeile: kein Widerruf (der Zugang ist schon ungueltig)', async () => {
|
||||
const s = setup({ rows: [makeRow({ status: 'EXPIRED' })], handler: happy });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Entschluesselung scheitert: kein Aufruf, Zeile geloescht', async () => {
|
||||
const s = setup({ rows: [makeRow()], handler: happy, decryptFails: true });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
expect(s.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keine Zeile: 409 notConnected', async () => {
|
||||
const s = setup({ handler: happy });
|
||||
const err = await errOf(s.service.disconnect('t1', 'u1'));
|
||||
expect(err?.status).toBe(409);
|
||||
expect(err?.body.code).toBe('notConnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('Benutzer B ohne Zeile: 409 notConnected, obwohl A verbunden ist; Zugriff nur mit B gebunden', async () => {
|
||||
const s = setup({ rows: [makeRow({ userId: 'uA' })], handler: happy });
|
||||
const err = await errOf(s.service.getSession('t1', 'uB'));
|
||||
expect(err?.status).toBe(409);
|
||||
expect(err?.body.code).toBe('notConnected');
|
||||
expect(s.bound.every((b) => b.tenantId === 't1' && b.userId === 'uB')).toBe(true);
|
||||
const find = s.dbCalls.find((c) => c.op === 'findFirst')!;
|
||||
expect(find.args.where).toEqual({ tenantId: 't1', userId: 'uB' });
|
||||
});
|
||||
|
||||
it('abgelaufene Zeile oder andere Adresse: 409 connectionExpired', async () => {
|
||||
const expired = setup({ rows: [makeRow({ status: 'EXPIRED' })] });
|
||||
expect((await errOf(expired.service.getSession('t1', 'u1')))?.body.code).toBe(
|
||||
'connectionExpired',
|
||||
);
|
||||
const moved = setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })] });
|
||||
const err = await errOf(moved.service.getSession('t1', 'u1'));
|
||||
expect(err?.status).toBe(409);
|
||||
expect(err?.body.code).toBe('connectionExpired');
|
||||
});
|
||||
|
||||
it('bereits toter Zugangsschluessel: Konto wird abgelaufen gesetzt, kein Transportaufruf', async () => {
|
||||
const s = setup({ rows: [makeRow()], handler: happy });
|
||||
s.gate.markDead(credentialKeyOf('enc(app-pw-123)'));
|
||||
const err = await errOf(s.service.getSession('t1', 'u1'));
|
||||
expect(err?.status).toBe(409);
|
||||
expect(err?.body.code).toBe('connectionExpired');
|
||||
expect(s.rows[0].status).toBe('EXPIRED');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
expect(s.dbCalls.find((c) => c.op === 'updateMany')?.args.where).toMatchObject({
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
it('Entschluesselung scheitert: 500 accountBroken', async () => {
|
||||
const s = setup({ rows: [makeRow()], decryptFails: true });
|
||||
const err = await errOf(s.service.getSession('t1', 'u1'));
|
||||
expect(err?.status).toBe(500);
|
||||
expect(err?.body.code).toBe('accountBroken');
|
||||
});
|
||||
|
||||
it('Erfolg: Sitzung mit Zugangsschluessel = erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert', async () => {
|
||||
const s = setup({ rows: [makeRow()] });
|
||||
const session = await s.service.getSession('t1', 'u1');
|
||||
expect(session).toEqual({
|
||||
baseUrl: BASE,
|
||||
ncUserId: 'anna',
|
||||
authorization: AUTH_APP,
|
||||
credentialKey: createHash('sha256').update('enc(app-pw-123)').digest('hex').slice(0, 16),
|
||||
});
|
||||
});
|
||||
|
||||
it('nicht eingerichtet: 409 notConfigured', async () => {
|
||||
const s = setup({ base: null });
|
||||
expect((await errOf(s.service.getSession('t1', 'u1')))?.body.code).toBe('notConfigured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('ohne Zeile: account null; nicht eingerichtet: account null', async () => {
|
||||
expect((await setup().service.getStatus('t1', 'u1')).account).toBeNull();
|
||||
expect((await setup({ base: null }).service.getStatus('t1', 'u1')).account).toBeNull();
|
||||
});
|
||||
|
||||
it('aktiv: verbunden mit Namen, Weg und Zeitpunkt, ohne Geheimnis', async () => {
|
||||
const view = await setup({ rows: [makeRow()] }).service.getStatus('t1', 'u1');
|
||||
expect(view.account).toEqual({
|
||||
connected: true,
|
||||
expired: false,
|
||||
status: 'ACTIVE',
|
||||
ncUserId: 'anna',
|
||||
displayName: 'Anna Müller',
|
||||
connectedVia: 'PASSWORD',
|
||||
connectedAt: '2026-10-02T10:00:00.000Z',
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain('app-pw-123');
|
||||
expect(JSON.stringify(view)).not.toContain('enc(');
|
||||
});
|
||||
|
||||
it('EXPIRED oder andere Adresse: status EXPIRED', async () => {
|
||||
const a = await setup({ rows: [makeRow({ status: 'EXPIRED' })] }).service.getStatus('t1', 'u1');
|
||||
expect(a.account).toMatchObject({ connected: false, expired: true, status: 'EXPIRED' });
|
||||
const b = await setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })] }).service.getStatus(
|
||||
't1',
|
||||
'u1',
|
||||
);
|
||||
expect(b.account).toMatchObject({ connected: false, expired: true, status: 'EXPIRED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zeilenzugriff', () => {
|
||||
let s: ReturnType<typeof setup>;
|
||||
beforeEach(() => {
|
||||
s = setup({ rows: [makeRow()], handler: happy });
|
||||
});
|
||||
|
||||
it('jeder Kontozugriff nutzt forTenant mit Mandant UND Benutzer des Aufrufers', async () => {
|
||||
await s.service.getStatus('t1', 'u1');
|
||||
await s.service.getSession('t1', 'u1');
|
||||
await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim');
|
||||
await s.service.markExpired('t1', 'u1');
|
||||
await s.service.disconnect('t1', 'u1').catch(() => undefined);
|
||||
expect(s.bound.length).toBeGreaterThan(0);
|
||||
expect(s.bound.every((b) => b.tenantId === 't1' && b.userId === 'u1')).toBe(true);
|
||||
for (const call of s.dbCalls) {
|
||||
const where = call.args.where?.tenantId_userId ?? call.args.where;
|
||||
expect(where, call.op).toMatchObject({ tenantId: 't1', userId: 'u1' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anmeldename = Basic-Benutzer (E-Mail-Anmeldung, gemessen gegen Nextcloud 34)', () => {
|
||||
const emailHandler = (req: NcTransportRequest) => happy(req);
|
||||
|
||||
it('Anmeldung mit E-Mail: cloud/user, Speichern und Sitzung nutzen die E-Mail als Basic-Benutzer, die Kennung bleibt fuer Pfade', async () => {
|
||||
const s = setup({ handler: emailHandler });
|
||||
await s.service.connectWithPassword('t1', 'u1', 'anna@example.com', 'geheim');
|
||||
expect(s.calls[1].url).toBe('http://cloud.example/ocs/v2.php/cloud/user');
|
||||
expect(s.calls[1].headers.authorization).toBe(AUTH_EMAIL_APP);
|
||||
const upsert = s.dbCalls.find((c) => c.op === 'upsert');
|
||||
expect(upsert?.args.create).toMatchObject({
|
||||
ncUserId: 'anna',
|
||||
ncLoginName: 'anna@example.com',
|
||||
});
|
||||
const session = await s.service.getSession('t1', 'u1');
|
||||
expect(session.ncUserId).toBe('anna');
|
||||
expect(session.authorization).toBe(AUTH_EMAIL_APP);
|
||||
});
|
||||
|
||||
it('Trennen widerruft mit dem Anmeldenamen der Ausstellung', async () => {
|
||||
const s = setup({ rows: [makeRow({ ncLoginName: 'anna@example.com' })], handler: happy });
|
||||
await s.service.disconnect('t1', 'u1');
|
||||
expect(s.calls).toHaveLength(1);
|
||||
expect(s.calls[0].headers.authorization).toBe(AUTH_EMAIL_APP);
|
||||
});
|
||||
|
||||
it('Konto aus der Zeit vor der Spalte (ncLoginName null): Kennung ist der Basic-Benutzer', async () => {
|
||||
const s = setup({ rows: [makeRow({ ncLoginName: null })] });
|
||||
expect((await s.service.getSession('t1', 'u1')).authorization).toBe(AUTH_APP);
|
||||
});
|
||||
|
||||
it('Widerruf eines nicht gespeicherten Zugangs nutzt den eingegebenen Anmeldenamen', async () => {
|
||||
const s = setup({
|
||||
upsertFails: true,
|
||||
handler: (req) =>
|
||||
req.url.endsWith('/cloud/user')
|
||||
? reply(200, ocs({ id: 'anna', 'display-name': 'Anna' }))
|
||||
: happy(req),
|
||||
});
|
||||
await errOf(s.service.connectWithPassword('t1', 'u1', 'anna@example.com', 'geheim'));
|
||||
const deletes = s.calls.filter((c) => c.method === 'DELETE');
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(deletes[0].headers.authorization).toBe(AUTH_EMAIL_APP);
|
||||
});
|
||||
|
||||
it('Browser-Anmeldung: loginName der Nextcloud wird als Basic-Benutzer gespeichert', async () => {
|
||||
const s = setup({
|
||||
handler: (req) => {
|
||||
if (req.url === 'http://cloud.example/index.php/login/v2') {
|
||||
return reply(200, {
|
||||
poll: { token: TOKEN128, endpoint: 'x' },
|
||||
login: `http://cloud.example/login/v2/flow/${TOKEN128}`,
|
||||
});
|
||||
}
|
||||
if (req.url.endsWith('/login/v2/poll')) {
|
||||
return reply(200, {
|
||||
server: 'x',
|
||||
loginName: 'anna@example.com',
|
||||
appPassword: 'app-pw-123',
|
||||
});
|
||||
}
|
||||
return happy(req);
|
||||
},
|
||||
});
|
||||
const { flowId } = await s.service.startFlow('t1', 'u1');
|
||||
expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'connected' });
|
||||
expect(s.dbCalls.find((c) => c.op === 'upsert')?.args.create).toMatchObject({
|
||||
ncUserId: 'anna',
|
||||
ncLoginName: 'anna@example.com',
|
||||
connectedVia: 'LOGIN_FLOW',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { HttpException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
type AuthFailure,
|
||||
authFailureCode,
|
||||
authFailureToException,
|
||||
getAppPassword,
|
||||
getCurrentUser,
|
||||
pollLoginFlow,
|
||||
revokeAppPassword,
|
||||
startLoginFlow,
|
||||
} from './nextcloud-auth-client';
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import {
|
||||
type NcSession,
|
||||
type NextcloudFilesAccountView,
|
||||
type NextcloudFilesStatusView,
|
||||
ncErrorDefault,
|
||||
} from './nextcloud-files.types';
|
||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||
import { basicAuth, NEXTCLOUD_TRANSPORT, type NextcloudTransport } from './nextcloud-http';
|
||||
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
|
||||
type ConnectMethod = 'PASSWORD' | 'LOGIN_FLOW';
|
||||
|
||||
interface AccountRow {
|
||||
baseUrl: string;
|
||||
ncUserId: string;
|
||||
/** Basic-Benutzer des App-Passworts (Anmeldename bei der Ausstellung); null = ncUserId. */
|
||||
ncLoginName: string | null;
|
||||
ncDisplayName: string | null;
|
||||
encryptedAppPassword: string;
|
||||
status: 'ACTIVE' | 'EXPIRED';
|
||||
connectedVia: ConnectMethod;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type FlowPollResult =
|
||||
| { state: 'pending' }
|
||||
| { state: 'connected' }
|
||||
| { state: 'failed'; code: string; message: string };
|
||||
|
||||
/** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert: Zugangsschluessel der Aufrufsperre. */
|
||||
export function credentialKeyOf(encryptedAppPassword: string): string {
|
||||
return createHash('sha256').update(encryptedAppPassword).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konto je Benutzer (quick-261008-mzu): verbinden mit Passwort oder per
|
||||
* Browser-Anmeldung (Login Flow v2), trennen mit Widerruf, Sitzung fuer die
|
||||
* Dateiaufrufe. Gesamter Zugriff auf `nextcloudFilesAccount` mit der
|
||||
* Benutzerkennung aus dem Token liegt ausschliesslich hier — jede Methode
|
||||
* bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die
|
||||
* Zeilenregel laesst nur eigene Zeilen zu, und jedes `where` traegt beides.
|
||||
*
|
||||
* Geheimnisse: das echte Passwort lebt nur in `connectWithPassword`, das App-
|
||||
* Passwort wird mit `CryptoService.encrypt` abgelegt und nur in `getSession`
|
||||
* entschluesselt. Nichts davon steht in einer Antwort, einem Log oder einem
|
||||
* Fehler.
|
||||
*
|
||||
* App-Passwort-Hygiene (D-P): ein frisch ausgestelltes App-Passwort, das nicht
|
||||
* gespeichert werden konnte, wird sofort widerrufen; beim erneuten Verbinden
|
||||
* wird das alte (gleiche Adresse) zuerst widerrufen; ein Zugang fuer eine
|
||||
* andere Adresse wird nie an diese gesendet.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextcloudFilesAccountService {
|
||||
private readonly logger = new Logger(NextcloudFilesAccountService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CryptoService,
|
||||
private readonly settings: NextcloudFilesSettingsService,
|
||||
private readonly guard: NextcloudLoginGuard,
|
||||
private readonly flows: LoginFlowStore,
|
||||
private readonly gate: NextcloudCallGate,
|
||||
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
|
||||
) {
|
||||
// Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr.
|
||||
this.settings.onAddressChange((tenantId) => this.flows.clearTenant(tenantId));
|
||||
}
|
||||
|
||||
// --- Zeilenzugriff (jeweils eigener, an Mandant UND Benutzer gebundener Klient) ----------
|
||||
|
||||
private async findAccount(tenantId: string, userId: string): Promise<AccountRow | null> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.nextcloudFilesAccount.findFirst({ where: { tenantId, userId } });
|
||||
return (row as AccountRow | null) ?? null;
|
||||
}
|
||||
|
||||
private async upsertAccount(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
data: {
|
||||
baseUrl: string;
|
||||
ncUserId: string;
|
||||
ncLoginName: string;
|
||||
ncDisplayName: string | null;
|
||||
encryptedAppPassword: string;
|
||||
connectedVia: ConnectMethod;
|
||||
},
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await tenantPrisma.nextcloudFilesAccount.upsert({
|
||||
where: { tenantId_userId: { tenantId, userId } },
|
||||
create: { tenantId, userId, ...data, status: 'ACTIVE' },
|
||||
update: { ...data, status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteAccount(tenantId: string, userId: string): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await tenantPrisma.nextcloudFilesAccount.deleteMany({ where: { tenantId, userId } });
|
||||
}
|
||||
|
||||
/** Markiert das eigene Konto als abgelaufen (App-Passwort wurde von Nextcloud abgelehnt). */
|
||||
async markExpired(tenantId: string, userId: string): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await tenantPrisma.nextcloudFilesAccount.updateMany({
|
||||
where: { tenantId, userId, status: 'ACTIVE' },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Stand ------------------------------------------------------------------------------
|
||||
|
||||
async getStatus(tenantId: string, userId: string): Promise<NextcloudFilesStatusView> {
|
||||
const base = await this.settings.getStatus(tenantId);
|
||||
if (!base.configured) return { ...base, account: null };
|
||||
const row = await this.findAccount(tenantId, userId);
|
||||
if (!row) return { ...base, account: null };
|
||||
const expired =
|
||||
row.status !== 'ACTIVE' ||
|
||||
row.baseUrl !== base.serverUrl ||
|
||||
this.gate.isDead(credentialKeyOf(row.encryptedAppPassword));
|
||||
const account: NextcloudFilesAccountView = {
|
||||
connected: !expired,
|
||||
expired,
|
||||
status: expired ? 'EXPIRED' : 'ACTIVE',
|
||||
ncUserId: row.ncUserId,
|
||||
displayName: row.ncDisplayName,
|
||||
connectedVia: row.connectedVia,
|
||||
connectedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
return { ...base, account };
|
||||
}
|
||||
|
||||
// --- Verbinden mit Passwort -------------------------------------------------------------------
|
||||
|
||||
async connectWithPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
loginName: string,
|
||||
password: string,
|
||||
): Promise<NextcloudFilesStatusView> {
|
||||
const baseUrl = await this.requireBaseUrl(tenantId);
|
||||
const scope = new URL(baseUrl).origin;
|
||||
this.guard.checkPasswordAttempt(userId, scope);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------
|
||||
|
||||
async startFlow(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
): Promise<{ flowId: string; loginUrl: string; expiresAt: string }> {
|
||||
const baseUrl = await this.requireBaseUrl(tenantId);
|
||||
this.guard.checkFlowStart(userId);
|
||||
const started = await startLoginFlow(this.transport, this.gate, baseUrl);
|
||||
if (!started.ok) throw authFailureToException(started);
|
||||
const entry = this.flows.create(tenantId, userId, baseUrl, started.pollToken);
|
||||
return {
|
||||
flowId: entry.flowId,
|
||||
loginUrl: started.loginUrl,
|
||||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async pollFlow(tenantId: string, userId: string, flowId: string): Promise<FlowPollResult> {
|
||||
const found = this.flows.lookup(flowId, tenantId, userId);
|
||||
if (found.state === 'missing') throw ncErrorDefault('notFound');
|
||||
if (found.state === 'expired') {
|
||||
this.flows.remove(flowId);
|
||||
throw ncErrorDefault('flowExpired');
|
||||
}
|
||||
const entry = found.entry;
|
||||
|
||||
// Die Adresse darf sich seit dem Start nicht geaendert haben.
|
||||
const current = await this.settings.getBaseUrl(tenantId);
|
||||
if (current !== entry.baseUrl) {
|
||||
this.flows.remove(flowId);
|
||||
throw ncErrorDefault('flowExpired');
|
||||
}
|
||||
|
||||
if (!this.flows.shouldPoll(entry)) return { state: 'pending' };
|
||||
this.flows.markPolled(entry);
|
||||
|
||||
const polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken);
|
||||
if (!polled.ok) throw authFailureToException(polled);
|
||||
if (polled.state === 'pending') return { state: 'pending' };
|
||||
|
||||
// Bestaetigt. Der Ablauf ist verbraucht (Nextcloud gibt das Ergebnis nur einmal heraus).
|
||||
const stillOpen = this.flows.get(flowId, tenantId, userId) !== undefined;
|
||||
this.flows.remove(flowId);
|
||||
const { loginName, appPassword } = polled;
|
||||
if (!stillOpen) {
|
||||
// Zwischenzeitlich abgebrochen: der frisch ausgestellte Zugang darf nirgends liegen bleiben.
|
||||
await this.revokeFresh(entry.baseUrl, loginName, appPassword);
|
||||
return this.failed(ncErrorDefault('flowExpired'));
|
||||
}
|
||||
|
||||
const ncUser = await getCurrentUser(
|
||||
this.transport,
|
||||
this.gate,
|
||||
entry.baseUrl,
|
||||
loginName,
|
||||
appPassword,
|
||||
);
|
||||
if (!ncUser.ok) {
|
||||
await this.revokeFresh(entry.baseUrl, loginName, appPassword);
|
||||
return this.failed(authFailureToException(unexpectedCredentials(ncUser)));
|
||||
}
|
||||
try {
|
||||
await this.storeAppPassword(
|
||||
tenantId,
|
||||
userId,
|
||||
entry.baseUrl,
|
||||
loginName,
|
||||
ncUser,
|
||||
appPassword,
|
||||
'LOGIN_FLOW',
|
||||
);
|
||||
} catch (err) {
|
||||
return this.failed(err);
|
||||
}
|
||||
return { state: 'connected' };
|
||||
}
|
||||
|
||||
async cancelFlow(tenantId: string, userId: string, flowId: string): Promise<{ cancelled: true }> {
|
||||
const found = this.flows.lookup(flowId, tenantId, userId);
|
||||
if (found.state === 'missing') throw ncErrorDefault('notFound');
|
||||
this.flows.remove(flowId);
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
private failed(err: unknown): FlowPollResult {
|
||||
if (err instanceof HttpException) {
|
||||
const body = err.getResponse() as { code?: string; message?: string };
|
||||
return {
|
||||
state: 'failed',
|
||||
code: body.code ?? 'nextcloudError',
|
||||
message: body.message ?? ncErrorDefault('nextcloudError').message,
|
||||
};
|
||||
}
|
||||
const fallback = ncErrorDefault('nextcloudError');
|
||||
return { state: 'failed', code: 'nextcloudError', message: fallback.message };
|
||||
}
|
||||
|
||||
// --- Trennen ----------------------------------------------------------------------------------
|
||||
|
||||
async disconnect(tenantId: string, userId: string): Promise<{ disconnected: true }> {
|
||||
const row = await this.findAccount(tenantId, userId);
|
||||
if (!row) throw ncErrorDefault('notConnected');
|
||||
|
||||
const current = await this.settings.getBaseUrl(tenantId);
|
||||
// Widerrufen nur dort, wo der Zugang gilt: aktives Konto UND gleiche Adresse (nie an einen anderen Host).
|
||||
if (row.status === 'ACTIVE' && current !== null && current === row.baseUrl) {
|
||||
let appPassword: string | null = null;
|
||||
try {
|
||||
appPassword = this.crypto.decrypt(row.encryptedAppPassword);
|
||||
} catch {
|
||||
this.logger.error(
|
||||
`App-Passwort eines Kontos ließ sich nicht entschlüsseln (Mandant ${tenantId}); Konto wird ohne Widerruf entfernt`,
|
||||
);
|
||||
}
|
||||
if (appPassword !== null) {
|
||||
await this.revokeBestEffort(
|
||||
row.baseUrl,
|
||||
basicUserOf(row),
|
||||
appPassword,
|
||||
credentialKeyOf(row.encryptedAppPassword),
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.deleteAccount(tenantId, userId);
|
||||
return { disconnected: true };
|
||||
}
|
||||
|
||||
// --- Sitzung fuer die Dateiaufrufe --------------------------------------------------------------
|
||||
|
||||
async getSession(tenantId: string, userId: string): Promise<NcSession> {
|
||||
const baseUrl = await this.requireBaseUrl(tenantId);
|
||||
const row = await this.findAccount(tenantId, userId);
|
||||
if (!row) throw ncErrorDefault('notConnected');
|
||||
if (row.status !== 'ACTIVE' || row.baseUrl !== baseUrl) {
|
||||
throw ncErrorDefault('connectionExpired');
|
||||
}
|
||||
const credentialKey = credentialKeyOf(row.encryptedAppPassword);
|
||||
if (this.gate.isDead(credentialKey)) {
|
||||
await this.markExpired(tenantId, userId);
|
||||
throw ncErrorDefault('connectionExpired');
|
||||
}
|
||||
let appPassword: string;
|
||||
try {
|
||||
appPassword = this.crypto.decrypt(row.encryptedAppPassword);
|
||||
} catch {
|
||||
this.logger.error(`Gespeichertes App-Passwort ist nicht lesbar (Mandant ${tenantId})`);
|
||||
throw ncErrorDefault('accountBroken');
|
||||
}
|
||||
return {
|
||||
baseUrl: row.baseUrl,
|
||||
ncUserId: row.ncUserId,
|
||||
authorization: basicAuth(basicUserOf(row), appPassword),
|
||||
credentialKey,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Hilfen ---------------------------------------------------------------------------------------
|
||||
|
||||
private async requireBaseUrl(tenantId: string): Promise<string> {
|
||||
const baseUrl = await this.settings.getBaseUrl(tenantId);
|
||||
if (baseUrl === null) throw ncErrorDefault('notConfigured');
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-Passwort ablegen (D-P): zuerst das alte Passwort derselben Adresse
|
||||
* widerrufen, dann verschluesseln und speichern. Scheitert etwas, wird das
|
||||
* FRISCHE Passwort sofort widerrufen und der Fehler weitergegeben.
|
||||
*/
|
||||
private async storeAppPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
ncUser: { id: string; displayName: string | null },
|
||||
appPassword: string,
|
||||
method: ConnectMethod,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.revokePrevious(tenantId, userId, baseUrl);
|
||||
const encryptedAppPassword = this.crypto.encrypt(appPassword);
|
||||
await this.upsertAccount(tenantId, userId, {
|
||||
baseUrl,
|
||||
ncUserId: ncUser.id,
|
||||
ncLoginName: loginName,
|
||||
ncDisplayName: ncUser.displayName,
|
||||
encryptedAppPassword,
|
||||
connectedVia: method,
|
||||
});
|
||||
} catch (err) {
|
||||
await this.revokeFresh(baseUrl, loginName, appPassword);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Das alte App-Passwort derselben Adresse widerrufen (best effort, nie ein Fehler nach aussen). */
|
||||
private async revokePrevious(tenantId: string, userId: string, baseUrl: string): Promise<void> {
|
||||
let old: AccountRow | null;
|
||||
try {
|
||||
old = await this.findAccount(tenantId, userId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!old || old.baseUrl !== baseUrl) return;
|
||||
let oldPassword: string;
|
||||
try {
|
||||
oldPassword = this.crypto.decrypt(old.encryptedAppPassword);
|
||||
} catch {
|
||||
this.logger.warn(`Altes App-Passwort nicht lesbar (Mandant ${tenantId}); kein Widerruf`);
|
||||
return;
|
||||
}
|
||||
await this.revokeBestEffort(
|
||||
old.baseUrl,
|
||||
basicUserOf(old),
|
||||
oldPassword,
|
||||
credentialKeyOf(old.encryptedAppPassword),
|
||||
);
|
||||
}
|
||||
|
||||
private async revokeFresh(
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<void> {
|
||||
await this.revokeBestEffort(baseUrl, loginName, appPassword);
|
||||
}
|
||||
|
||||
private async revokeBestEffort(
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
credentialKey?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await revokeAppPassword(
|
||||
this.transport,
|
||||
this.gate,
|
||||
baseUrl,
|
||||
loginName,
|
||||
appPassword,
|
||||
credentialKey,
|
||||
);
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`);
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Basic-Benutzer eines App-Passworts ist der Anmeldename der Ausstellung
|
||||
* (gemessen: mit der E-Mail-Adresse ausgestellt, antwortet Nextcloud auf die
|
||||
* Kennung mit 401). Konten vor dieser Spalte haben keinen: dann gilt die Kennung.
|
||||
*/
|
||||
function basicUserOf(row: Pick<AccountRow, 'ncUserId' | 'ncLoginName'>): string {
|
||||
return row.ncLoginName ?? row.ncUserId;
|
||||
}
|
||||
|
||||
function failureLabel(failure: AuthFailure): string {
|
||||
return authFailureCode(failure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein 401 auf `cloud/user` mit einem GERADE ausgestellten App-Passwort ist kein
|
||||
* "falsches Passwort" fuer den Benutzer, sondern eine unerwartete Antwort.
|
||||
*/
|
||||
function unexpectedCredentials(failure: AuthFailure): AuthFailure {
|
||||
return failure.kind === 'credentials' ? { ...failure, kind: 'upstream' } : failure;
|
||||
}
|
||||
@@ -73,6 +73,34 @@ describe('NextcloudFilesController — Metadaten', () => {
|
||||
expect(route('getSettings')).toEqual([0, 'settings']);
|
||||
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||
expect(route('testSettings')).toEqual([1, 'settings/test']);
|
||||
expect(route('connectPassword')).toEqual([1, 'connect/password']);
|
||||
expect(route('startFlow')).toEqual([1, 'connect/flow']);
|
||||
expect(route('disconnect')).toEqual([3, 'connect']);
|
||||
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
||||
expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']);
|
||||
});
|
||||
|
||||
it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => {
|
||||
for (const name of ['connectPassword', 'startFlow', 'pollFlow', 'cancelFlow', 'disconnect']) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Passwort- und Browser-Anmeldung antworten 200, nicht 201', () => {
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200);
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.startFlow)).toBe(200);
|
||||
});
|
||||
|
||||
it('pollFlow und cancelFlow stehen nach allen statischen Handlern', () => {
|
||||
const names = routeHandlers();
|
||||
const staticLast = Math.max(
|
||||
...names
|
||||
.filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':'))
|
||||
.map((n) => names.indexOf(n)),
|
||||
);
|
||||
expect(names.indexOf('pollFlow')).toBeGreaterThan(staticLast);
|
||||
expect(names.indexOf('cancelFlow')).toBeGreaterThan(staticLast);
|
||||
});
|
||||
|
||||
it('POST settings/test antwortet 200, nicht 201', () => {
|
||||
@@ -87,34 +115,85 @@ describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Paramete
|
||||
});
|
||||
|
||||
describe('NextcloudFilesController — Delegation', () => {
|
||||
const FLOW = '8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
|
||||
const userReq = (tenantId: string | undefined, userId: string | undefined) =>
|
||||
({ tenantId, user: userId ? { id: userId } : undefined }) as any;
|
||||
|
||||
function makeSettings() {
|
||||
return {
|
||||
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
|
||||
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
|
||||
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
it('reicht den Mandanten aus dem Token weiter, nie aus dem Body', async () => {
|
||||
function makeAccount() {
|
||||
return {
|
||||
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
|
||||
connectWithPassword: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
|
||||
startFlow: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
pollFlow: vi.fn(async (..._a: unknown[]) => ({ state: 'pending' })),
|
||||
cancelFlow: vi.fn(async (..._a: unknown[]) => ({ cancelled: true })),
|
||||
disconnect: vi.fn(async (..._a: unknown[]) => ({ disconnected: true })),
|
||||
};
|
||||
}
|
||||
|
||||
it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => {
|
||||
const settings = makeSettings();
|
||||
const controller = new NextcloudFilesController(settings as any);
|
||||
await controller.getStatus(req('t1'));
|
||||
const account = makeAccount();
|
||||
const controller = new NextcloudFilesController(settings as any, account as any);
|
||||
await controller.getStatus(userReq('t1', 'u1'));
|
||||
await controller.getSettings(req('t1'));
|
||||
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
||||
await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
||||
expect(settings.getStatus).toHaveBeenCalledWith('t1');
|
||||
await controller.connectPassword(userReq('t1', 'u1'), {
|
||||
loginName: ' anna ',
|
||||
password: 'geheim',
|
||||
userId: 'fremd',
|
||||
} as any);
|
||||
await controller.startFlow(userReq('t1', 'u1'));
|
||||
await controller.pollFlow(userReq('t1', 'u1'), FLOW);
|
||||
await controller.cancelFlow(userReq('t1', 'u1'), FLOW);
|
||||
await controller.disconnect(userReq('t1', 'u1'));
|
||||
expect(account.getStatus).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(settings.getSettings).toHaveBeenCalledWith('t1');
|
||||
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
|
||||
expect(settings.testAddress).toHaveBeenCalledWith('https://x.example');
|
||||
expect(account.connectWithPassword).toHaveBeenCalledWith('t1', 'u1', 'anna', 'geheim');
|
||||
expect(account.startFlow).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(account.pollFlow).toHaveBeenCalledWith('t1', 'u1', FLOW);
|
||||
expect(account.cancelFlow).toHaveBeenCalledWith('t1', 'u1', FLOW);
|
||||
expect(account.disconnect).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext: ForbiddenException', async () => {
|
||||
const controller = new NextcloudFilesController(makeSettings() as any);
|
||||
await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any);
|
||||
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
|
||||
const account = makeAccount();
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, account as any);
|
||||
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(
|
||||
controller.connectPassword(userReq('t1', undefined), {
|
||||
loginName: 'anna',
|
||||
password: 'x',
|
||||
} as any),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.disconnect(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(account.getStatus).not.toHaveBeenCalled();
|
||||
expect(account.connectWithPassword).not.toHaveBeenCalled();
|
||||
expect(account.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
|
||||
import {
|
||||
SaveNextcloudFilesSettingsDto,
|
||||
TestNextcloudFilesSettingsDto,
|
||||
} from './dto/nextcloud-files-settings.dto';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||
|
||||
/**
|
||||
@@ -50,7 +55,10 @@ import { NextcloudFilesSettingsService } from './nextcloud-files-settings.servic
|
||||
@Controller('modules/nextcloud-files')
|
||||
@UseModule('nextcloud-files')
|
||||
export class NextcloudFilesController {
|
||||
constructor(private readonly settings: NextcloudFilesSettingsService) {}
|
||||
constructor(
|
||||
private readonly settings: NextcloudFilesSettingsService,
|
||||
private readonly account: NextcloudFilesAccountService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
@@ -60,9 +68,18 @@ export class NextcloudFilesController {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/** Die Benutzerkennung kommt NUR aus dem Token, nie aus Body oder Query. */
|
||||
private requireUserId(req: AuthenticatedRequest): string {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Kein Benutzerkontext');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus(@Req() req: AuthenticatedRequest) {
|
||||
return this.settings.getStatus(this.requireTenantId(req));
|
||||
return this.account.getStatus(this.requireTenantId(req), this.requireUserId(req));
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
@@ -84,4 +101,46 @@ export class NextcloudFilesController {
|
||||
this.requireTenantId(req);
|
||||
return this.settings.testAddress(dto.baseUrl);
|
||||
}
|
||||
|
||||
// --- Konto verbinden (Benutzen) ------------------------------------------------------
|
||||
|
||||
@Post('connect/password')
|
||||
@HttpCode(200)
|
||||
async connectPassword(@Req() req: AuthenticatedRequest, @Body() dto: ConnectPasswordDto) {
|
||||
return this.account.connectWithPassword(
|
||||
this.requireTenantId(req),
|
||||
this.requireUserId(req),
|
||||
dto.loginName.trim(),
|
||||
dto.password,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('connect/flow')
|
||||
@HttpCode(200)
|
||||
async startFlow(@Req() req: AuthenticatedRequest) {
|
||||
return this.account.startFlow(this.requireTenantId(req), this.requireUserId(req));
|
||||
}
|
||||
|
||||
@Delete('connect')
|
||||
async disconnect(@Req() req: AuthenticatedRequest) {
|
||||
return this.account.disconnect(this.requireTenantId(req), this.requireUserId(req));
|
||||
}
|
||||
|
||||
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
|
||||
|
||||
@Get('connect/flow/:flowId')
|
||||
async pollFlow(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('flowId', new ParseUUIDPipe()) flowId: string,
|
||||
) {
|
||||
return this.account.pollFlow(this.requireTenantId(req), this.requireUserId(req), flowId);
|
||||
}
|
||||
|
||||
@Delete('connect/flow/:flowId')
|
||||
async cancelFlow(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('flowId', new ParseUUIDPipe()) flowId: string,
|
||||
) {
|
||||
return this.account.cancelFlow(this.requireTenantId(req), this.requireUserId(req), flowId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { NextcloudFilesController } from './nextcloud-files.controller';
|
||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import {
|
||||
defaultStatusFetcher,
|
||||
NEXTCLOUD_STATUS_FETCHER,
|
||||
NextcloudFilesSettingsService,
|
||||
} from './nextcloud-files-settings.service';
|
||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
|
||||
/**
|
||||
* Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in
|
||||
@@ -23,11 +25,19 @@ import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||
controllers: [NextcloudFilesController],
|
||||
providers: [
|
||||
NextcloudFilesSettingsService,
|
||||
NextcloudFilesAccountService,
|
||||
NextcloudLoginGuard,
|
||||
LoginFlowStore,
|
||||
NextcloudCallGate,
|
||||
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
|
||||
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
|
||||
],
|
||||
exports: [NextcloudCallGate, NextcloudFilesSettingsService, NEXTCLOUD_TRANSPORT],
|
||||
exports: [
|
||||
NextcloudCallGate,
|
||||
NextcloudFilesSettingsService,
|
||||
NextcloudFilesAccountService,
|
||||
NEXTCLOUD_TRANSPORT,
|
||||
],
|
||||
})
|
||||
export class NextcloudFilesModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(NextcloudFilesModule.name);
|
||||
|
||||
@@ -224,9 +224,13 @@ export interface NextcloudFilesAccountView {
|
||||
connected: boolean;
|
||||
/** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */
|
||||
expired: boolean;
|
||||
/** `ACTIVE`, oder `EXPIRED` bei gespeichertem Ablauf, anderer Adresse oder totem Zugangsschluessel. */
|
||||
status: 'ACTIVE' | 'EXPIRED';
|
||||
ncUserId: string | null;
|
||||
displayName: string | null;
|
||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
|
||||
/** Zeitpunkt der Verbindung (ISO), nie ein Geheimnis. */
|
||||
connectedAt: string | null;
|
||||
}
|
||||
|
||||
export interface NextcloudFilesStatusView {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ncErrorDefault } from './nextcloud-files.types';
|
||||
|
||||
/**
|
||||
* Anmeldebremse des Moduls "Nextcloud-Dateien" (quick-261008-mzu, D-E/D-F, L-04).
|
||||
*
|
||||
* WARUM: Alle Tessera-Benutzer erreichen die Nextcloud von EINER Server-Adresse.
|
||||
* Die Brute-Force-Erkennung der Nextcloud sperrt je Adresse nach 10
|
||||
* Fehlanmeldungen in 30 Minuten — fuer ALLE Benutzer zugleich, und jeder
|
||||
* weitere Versuch waehrend der Sperre verlaengert sie. Tessera bremst deshalb
|
||||
* selbst, bevor es dazu kommt: hoechstens 3 Fehlversuche je Benutzer in 15
|
||||
* Minuten und 8 fuer den ganzen Server in 30 Minuten (jeweils unter der
|
||||
* Schwelle der Nextcloud). Ist die Grenze erreicht, antwortet Tessera mit 429
|
||||
* `tooManyAttempts`, OHNE Nextcloud anzusprechen. Die Zaehler liegen im
|
||||
* Arbeitsspeicher; ein Neustart vergisst sie (hinnehmbar — der Schutz der
|
||||
* Nextcloud selbst und die Aufrufsperre bleiben bestehen). Ein 429 der
|
||||
* Nextcloud behandelt die Aufrufsperre (`NextcloudCallGate`), nicht diese Klasse.
|
||||
*
|
||||
* Der Login Flow v2 zaehlt bei der Nextcloud nicht als Fehlanmeldung (gemessen),
|
||||
* bekommt deshalb nur eine eigene Startgrenze (10 je Benutzer in 10 Minuten)
|
||||
* und beruehrt die Fehlerzaehler nie.
|
||||
*/
|
||||
|
||||
export const USER_FAILURE_LIMIT = 3;
|
||||
export const USER_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
export const SERVER_FAILURE_LIMIT = 8;
|
||||
export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000;
|
||||
export const FLOW_START_LIMIT = 10;
|
||||
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
function prune(list: number[], now: number, windowMs: number): number[] {
|
||||
return list.filter((t) => now - t < windowMs);
|
||||
}
|
||||
|
||||
function tooMany(retryAfterMs: number) {
|
||||
return ncErrorDefault('tooManyAttempts', {
|
||||
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudLoginGuard {
|
||||
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
|
||||
now: () => number = () => Date.now();
|
||||
|
||||
private readonly userFailures = new Map<string, number[]>();
|
||||
private readonly serverFailures = new Map<string, number[]>();
|
||||
private readonly flowStarts = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
||||
* `tooManyAttempts` mit `retryAfterSeconds` (bis der aelteste gezaehlte
|
||||
* Fehlversuch das Fenster verlaesst). `scope` trennt Server, die verschiedene
|
||||
* Nextclouds ansprechen (Standard: eine gemeinsame Sperre).
|
||||
*/
|
||||
checkPasswordAttempt(userId: string, scope = ''): void {
|
||||
const now = this.now();
|
||||
const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS);
|
||||
const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS);
|
||||
this.userFailures.set(userId, mine);
|
||||
this.serverFailures.set(scope, server);
|
||||
|
||||
let waitMs = 0;
|
||||
if (mine.length >= USER_FAILURE_LIMIT) {
|
||||
waitMs = Math.max(waitMs, mine[0] + USER_FAILURE_WINDOW_MS - now);
|
||||
}
|
||||
if (server.length >= SERVER_FAILURE_LIMIT) {
|
||||
waitMs = Math.max(waitMs, server[0] + SERVER_FAILURE_WINDOW_MS - now);
|
||||
}
|
||||
if (waitMs > 0) throw tooMany(waitMs);
|
||||
}
|
||||
|
||||
recordFailure(userId: string, scope = ''): void {
|
||||
const now = this.now();
|
||||
const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS);
|
||||
mine.push(now);
|
||||
this.userFailures.set(userId, mine);
|
||||
const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS);
|
||||
server.push(now);
|
||||
this.serverFailures.set(scope, server);
|
||||
}
|
||||
|
||||
/** Eine gelungene Anmeldung loescht nur die Fehlversuche dieses Benutzers. */
|
||||
recordSuccess(userId: string): void {
|
||||
this.userFailures.delete(userId);
|
||||
}
|
||||
|
||||
/** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */
|
||||
checkFlowStart(userId: string): void {
|
||||
const now = this.now();
|
||||
const starts = prune(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS);
|
||||
if (starts.length >= FLOW_START_LIMIT) {
|
||||
this.flowStarts.set(userId, starts);
|
||||
throw tooMany(starts[0] + FLOW_START_WINDOW_MS - now);
|
||||
}
|
||||
starts.push(now);
|
||||
this.flowStarts.set(userId, starts);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Browser-Anmeldung (Login Flow v2) -----------------------------------------
|
||||
|
||||
export const FLOW_TTL_MS = 20 * 60 * 1000;
|
||||
export const FLOW_MAX_TOTAL = 200;
|
||||
export const FLOW_POLL_MIN_INTERVAL_MS = 1500;
|
||||
/** Abgelaufene Eintraege bleiben noch kurz, damit die Abfrage 410 statt 404 melden kann. */
|
||||
const FLOW_TOMBSTONE_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface FlowEntry {
|
||||
flowId: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
/** Adresse, fuer die der Ablauf gestartet wurde. */
|
||||
baseUrl: string;
|
||||
/** Abfrage-Token der Nextcloud — verlaesst den Server nie. */
|
||||
pollToken: string;
|
||||
expiresAt: number;
|
||||
lastPollAt: number;
|
||||
}
|
||||
|
||||
export type FlowLookup =
|
||||
| { state: 'ok'; entry: FlowEntry }
|
||||
| { state: 'expired' }
|
||||
| { state: 'missing' };
|
||||
|
||||
/**
|
||||
* Offene Browser-Anmeldungen im Arbeitsspeicher (D-F): hoechstens eine je
|
||||
* Benutzer (ein neuer Start ersetzt die alte), hoechstens 200 insgesamt,
|
||||
* 20 Minuten Lebensdauer. Ein Neustart der API verliert offene Abläufe — der
|
||||
* Benutzer startet dann einfach neu. Fremde Kennungen (anderer Benutzer oder
|
||||
* Mandant) sind nicht von unbekannten zu unterscheiden.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginFlowStore {
|
||||
now: () => number = () => Date.now();
|
||||
|
||||
private readonly flows = new Map<string, FlowEntry>();
|
||||
|
||||
private prune(): void {
|
||||
const now = this.now();
|
||||
for (const [id, entry] of this.flows) {
|
||||
if (now >= entry.expiresAt + FLOW_TOMBSTONE_MS) this.flows.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private liveCount(): number {
|
||||
const now = this.now();
|
||||
let n = 0;
|
||||
for (const entry of this.flows.values()) if (now < entry.expiresAt) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
create(tenantId: string, userId: string, baseUrl: string, pollToken: string): FlowEntry {
|
||||
this.prune();
|
||||
// Ein neuer Start ersetzt den alten desselben Benutzers.
|
||||
for (const [id, entry] of this.flows) {
|
||||
if (entry.tenantId === tenantId && entry.userId === userId) this.flows.delete(id);
|
||||
}
|
||||
if (this.liveCount() >= FLOW_MAX_TOTAL) throw ncErrorDefault('tooManyFlows');
|
||||
const now = this.now();
|
||||
const entry: FlowEntry = {
|
||||
flowId: randomUUID(),
|
||||
tenantId,
|
||||
userId,
|
||||
baseUrl,
|
||||
pollToken,
|
||||
expiresAt: now + FLOW_TTL_MS,
|
||||
lastPollAt: Number.NEGATIVE_INFINITY,
|
||||
};
|
||||
this.flows.set(entry.flowId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
lookup(flowId: string, tenantId: string, userId: string): FlowLookup {
|
||||
this.prune();
|
||||
const entry = this.flows.get(flowId);
|
||||
if (!entry || entry.tenantId !== tenantId || entry.userId !== userId) {
|
||||
return { state: 'missing' };
|
||||
}
|
||||
if (this.now() >= entry.expiresAt) return { state: 'expired' };
|
||||
return { state: 'ok', entry };
|
||||
}
|
||||
|
||||
/** Der Eintrag, falls er fuer diesen Benutzer noch lebt (sonst `undefined`). */
|
||||
get(flowId: string, tenantId: string, userId: string): FlowEntry | undefined {
|
||||
const found = this.lookup(flowId, tenantId, userId);
|
||||
return found.state === 'ok' ? found.entry : undefined;
|
||||
}
|
||||
|
||||
remove(flowId: string): void {
|
||||
this.flows.delete(flowId);
|
||||
}
|
||||
|
||||
/** Hoechstens eine Nextcloud-Abfrage je 1,5 s und Ablauf; schnellere Browser-Abfragen bleiben `pending`. */
|
||||
shouldPoll(entry: FlowEntry): boolean {
|
||||
return this.now() - entry.lastPollAt >= FLOW_POLL_MIN_INTERVAL_MS;
|
||||
}
|
||||
|
||||
markPolled(entry: FlowEntry): void {
|
||||
entry.lastPollAt = this.now();
|
||||
}
|
||||
|
||||
/** Nach einem Adresswechsel: alle Abläufe der Organisation verwerfen. */
|
||||
clearTenant(tenantId: string): void {
|
||||
for (const [id, entry] of this.flows) {
|
||||
if (entry.tenantId === tenantId) this.flows.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user