feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf

- Anmelde-Client (getapppassword, cloud/user, Widerruf, Login Flow v2 mit fester Abfrageadresse,
  Link aus Basis und Token neu gebaut), Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server,
  Ablaufspeicher für Browser-Anmeldungen (20 min, höchstens 200, eine je Benutzer)
- Kontodienst: Verbinden, Trennen mit Widerruf, Sitzung mit Zugangsschlüssel-Sperre,
  frisch ausgestellte oder ersetzte App-Passwörter bleiben nie verwaist; jeder Kontozugriff
  über forTenant mit Mandant UND Benutzer aus dem Token
- Migration 20261008183000: Spalte ncLoginName (App-Passwort gilt nur für den Anmeldenamen der
  Ausstellung, gemessen mit E-Mail-Anmeldung gegen Nextcloud 34)
- Verbindungsbildschirm und Kontoleiste, Texte de/en, RLS-Inventar fortgeschrieben,
  E2E-Skript e2e-connect.sh

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:02:45 +02:00
parent 960696745f
commit d00b6ff79f
25 changed files with 3533 additions and 55 deletions
@@ -0,0 +1,13 @@
-- 261008-mzu (Aufgabe 2) — Anmeldename des App-Passworts im Konto.
--
-- Warum: Ein App-Passwort gehoert zu dem Anmeldenamen, mit dem es ausgestellt wurde
-- (gemessen gegen Nextcloud 34: wer sich mit seiner E-Mail-Adresse anmeldet, bekommt
-- einen Zugang, der NUR mit der E-Mail-Adresse als Basic-Benutzer funktioniert; mit der
-- Nextcloud-Kennung `ncUserId` antwortet Nextcloud 401). Die Kennung `ncUserId` bleibt
-- fuer die Dateipfade (`/remote.php/dav/files/<kennung>/`) zustaendig, der Basic-Benutzer
-- steht jetzt in `ncLoginName`. NULL (Konten vor dieser Aenderung) heisst: Basic-Benutzer
-- ist `ncUserId`.
--
-- Nur eine zusaetzliche, optionale Spalte; die Zeilenschutz-Regeln der Tabelle bleiben
-- unveraendert.
ALTER TABLE "NextcloudFilesAccount" ADD COLUMN "ncLoginName" TEXT;
+2
View File
@@ -977,6 +977,8 @@ model NextcloudFilesAccount {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
baseUrl String
ncUserId String
// Anmeldename, mit dem das App-Passwort ausgestellt wurde (Basic-Benutzer). NULL = ncUserId.
ncLoginName String?
ncDisplayName String?
encryptedAppPassword String
status NextcloudFilesAccountStatus @default(ACTIVE)
@@ -12,8 +12,8 @@ import { NextcloudFilesController } from '../nextcloud-files/nextcloud-files.con
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from './module.guard';
import { ModuleRegistryController } from './module-registry.controller';
/**
* Metadaten-Beweis für die Freigabestufe Verwalten (261002-icv, L-04/L-09):
@@ -53,19 +53,25 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DkvController)).toBe('dkv-fleet');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard);
const names = methodsOf(DkvController).filter((n) => Reflect.hasMetadata('path', handler(DkvController, n)));
const names = methodsOf(DkvController).filter((n) =>
Reflect.hasMetadata('path', handler(DkvController, n)),
);
expect(names.length).toBe(11);
for (const name of names) {
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
}
});
it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])(
'ProxmoxController.%s verlangt Verwalten für proxmox',
(name) => {
expectManage(ProxmoxController, name, 'proxmox');
},
);
it.each([
'create',
'update',
'remove',
'poll',
'test',
'testDraft',
])('ProxmoxController.%s verlangt Verwalten für proxmox', (name) => {
expectManage(ProxmoxController, name, 'proxmox');
});
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
const fn = handler(ProxmoxController, 'list');
@@ -73,14 +79,25 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
(name) => {
expectManage(NextcloudStatusController, name, 'nextcloud-status');
},
);
it.each([
'create',
'update',
'remove',
'checkAll',
'checkOne',
'uploadLogo',
'removeLogo',
])('NextcloudStatusController.%s verlangt Verwalten für nextcloud-status', (name) => {
expectManage(NextcloudStatusController, name, 'nextcloud-status');
});
it.each(['list', 'logo', 'subscribe', 'unsubscribe', 'recentAlerts'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
it.each([
'list',
'logo',
'subscribe',
'unsubscribe',
'recentAlerts',
])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudStatusController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
@@ -128,6 +145,11 @@ describe('Umgestellte Handler (Verwalten)', () => {
// Spätere Aufgaben von quick-261008-mzu ergänzen diese Liste um ihre Benutzen-Handler.
it.each([
'getStatus',
'connectPassword',
'startFlow',
'pollFlow',
'cancelFlow',
'disconnect',
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudFilesController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
@@ -141,24 +163,27 @@ describe('Umgestellte Handler (Verwalten)', () => {
});
describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])(
'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(TendersController, name);
},
);
it.each([
'getSourceConfig',
'saveSourceConfig',
'pollNow',
])('TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
expectAdminOnly(TendersController, name);
});
it.each(['matrix', 'userAccess', 'create', 'remove'])(
'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleGrantsController, name);
},
);
it.each([
'matrix',
'userAccess',
'create',
'remove',
])('ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
expectAdminOnly(ModuleGrantsController, name);
});
it.each(['activate', 'deactivate'])(
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleRegistryController, name);
},
);
it.each([
'activate',
'deactivate',
])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
expectAdminOnly(ModuleRegistryController, name);
});
});
@@ -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);
}
}
}