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,115 @@
#!/usr/bin/env bash
# End-zu-Ende-Pruefung Aufgabe 2 (quick-261008-mzu): Verbinden mit Passwort, Zwei-Faktor-Konto,
# Browser-Anmeldung (Start/Abfrage/Abbruch), Wiederverbinden ohne verwaiste Zugaenge, Trennen mit
# Widerruf. Voraussetzung: lokaler Stack laeuft (neu gebaut), nc-test-setup.sh ist gelaufen.
# Die Zwei-Faktor-Anmeldung im Browser (Zugriff gewaehren) belegt der Playwright-Lauf, nicht dieses Skript.
set -euo pipefail
# shellcheck source=e2e-lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/e2e-lib.sh"
JAR="$E2E_TMP/admin.jar"
C="$API/modules/nextcloud-files"
e2e_login "$JAR"
e2e_activate "$JAR"
e2e_set_address "$JAR"
# Sauberer Start: Verbindung trennen (200, oder 409 wenn nicht verbunden).
code=$(e2e_status "$JAR" DELETE "$C/connect")
case "$code" in 200) ;; 409) e2e_contains "$E2E_TMP/body.out" 'notConnected' "Start: notConnected" ;; *) e2e_fail "DELETE connect (Start) -> $code" ;; esac
e2e_expect 0 "$(e2e_nc_tokens anna)" "Start: keine Tessera-Zugaenge fuer anna"
# 1. Verbinden mit Passwort
code=$(e2e_connect_anna "$JAR")
e2e_expect 200 "$code" "connect/password anna"
e2e_contains "$E2E_TMP/body.out" '"ncUserId":"anna"' "Antwort: ncUserId"
if grep -q -e 'User1-Pass' -e 'ncrypted' -e 'appPassword' "$E2E_TMP/body.out"; then e2e_fail "Antwort enthaelt ein Geheimnis"; fi
code=$(e2e_status "$JAR" GET "$C/status")
e2e_expect 200 "$code" "GET status"
e2e_contains "$E2E_TMP/body.out" '"status":"ACTIVE"' "status ACTIVE"
if grep -q -e 'User1-Pass' -e 'ncrypted' "$E2E_TMP/body.out"; then e2e_fail "status enthaelt ein Geheimnis"; fi
e2e_expect 1 "$(e2e_nc_tokens anna)" "genau ein Tessera-Zugang fuer anna"
# 2. Wiederverbinden: der alte Zugang wird widerrufen, es bleibt genau einer
code=$(e2e_connect_anna "$JAR")
e2e_expect 200 "$code" "Wiederverbinden anna"
e2e_expect 1 "$(e2e_nc_tokens anna)" "nach Wiederverbinden genau ein Tessera-Zugang"
# 2b. Anmeldung mit E-Mail-Adresse: das App-Passwort gehoert zum eingegebenen Anmeldenamen (gemessen:
# mit der Kennung als Basic-Benutzer antwortet Nextcloud 401). Der Widerruf beim Trennen gelingt nur,
# wenn Tessera den Anmeldenamen mitgespeichert hat; das belegt die Token-Zahl 0 danach.
NC_OCC user:setting anna settings email anna@example.com >/dev/null
code=$(e2e_status "$JAR" POST "$C/connect/password" '{"loginName":"anna@example.com","password":"User1-Pass-12345"}')
e2e_expect 200 "$code" "connect/password mit E-Mail"
e2e_contains "$E2E_TMP/body.out" '"ncUserId":"anna"' "E-Mail-Anmeldung: ncUserId ist die Kennung"
e2e_expect 1 "$(e2e_nc_tokens anna)" "E-Mail-Anmeldung: der alte Zugang wurde widerrufen"
code=$(e2e_status "$JAR" DELETE "$C/connect")
e2e_expect 200 "$code" "Trennen nach E-Mail-Anmeldung"
e2e_expect 0 "$(e2e_nc_tokens anna)" "Widerruf mit dem Anmeldenamen der Ausstellung"
code=$(e2e_connect_anna "$JAR")
e2e_expect 200 "$code" "wieder mit der Kennung verbinden"
# 3. Zweiter Tessera-Benutzer derselben Organisation sieht das Konto nicht (Mandant UND Benutzer)
e2e_second_user e2euser "$E2E_TMP/e2euser.jar"
e2e_grant_use "$JAR" "$E2E_TMP/e2euser.jar"
code=$(e2e_status "$E2E_TMP/e2euser.jar" GET "$C/status")
e2e_expect 200 "$code" "e2euser GET status"
e2e_contains "$E2E_TMP/body.out" '"account":null' "e2euser sieht anna's Konto nicht (account null)"
code=$(e2e_status "$E2E_TMP/e2euser.jar" DELETE "$C/connect")
e2e_expect 409 "$code" "e2euser kann annas Konto nicht trennen"
e2e_contains "$E2E_TMP/body.out" 'notConnected' "e2euser: notConnected"
e2e_expect 1 "$(e2e_nc_tokens anna)" "annas Zugang bleibt nach dem Trennversuch von e2euser"
# 4. Zwei-Faktor-Konto: 422 credentialsOrTwoFactor. Jeder Versuch zaehlt in Tessera als Fehlversuch
# (3 je Benutzer in 15 min); bei 429 startet die API neu und der Versuch wird einmal wiederholt.
zoe_attempt() {
e2e_status "$JAR" POST "$C/connect/password" '{"loginName":"zoe","password":"User2-Pass-12345"}'
}
code=$(zoe_attempt)
if [ "$code" = "429" ]; then
e2e_contains "$E2E_TMP/body.out" 'tooManyAttempts' "429 ist tooManyAttempts"
(cd "$E2E_DIR/../../../.." && docker compose restart api >/dev/null)
e2e_wait_health
e2e_login "$JAR"
code=$(zoe_attempt)
fi
e2e_expect 422 "$code" "zoe per Passwort"
e2e_contains "$E2E_TMP/body.out" 'credentialsOrTwoFactor' "zoe: credentialsOrTwoFactor"
e2e_expect 0 "$(e2e_nc_tokens zoe)" "zoe bekommt keinen Zugang"
# 5. Browser-Anmeldung: Start, Abfrage (noch nicht bestaetigt), Abbruch
code=$(e2e_status "$JAR" POST "$C/connect/flow")
e2e_expect 200 "$code" "connect/flow Start"
e2e_contains "$E2E_TMP/body.out" "\"loginUrl\":\"$NC_BASE/index.php/login/v2/flow/" "loginUrl zeigt auf die konfigurierte Adresse"
if grep -qiE '"(token|poll)' "$E2E_TMP/body.out"; then e2e_fail "Flow-Antwort traegt Token oder Poll-Feld"; fi
FLOW_ID=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["flowId"])' "$E2E_TMP/body.out")
code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID")
e2e_expect 200 "$code" "connect/flow Abfrage"
e2e_contains "$E2E_TMP/body.out" '"state":"pending"' "Abfrage: pending"
# Ein zweiter Tessera-Benutzer kennt die Kennung nicht: 404.
code=$(e2e_status "$E2E_TMP/e2euser.jar" GET "$C/connect/flow/$FLOW_ID")
e2e_expect 404 "$code" "fremder Benutzer fragt Ablauf ab"
code=$(e2e_status "$E2E_TMP/e2euser.jar" DELETE "$C/connect/flow/$FLOW_ID")
e2e_expect 404 "$code" "fremder Benutzer bricht Ablauf ab"
code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID")
e2e_expect 200 "$code" "eigener Ablauf lebt nach dem fremden Versuch"
code=$(e2e_status "$JAR" DELETE "$C/connect/flow/$FLOW_ID")
e2e_expect 200 "$code" "connect/flow Abbruch"
code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID")
e2e_expect 404 "$code" "abgebrochener Ablauf ist weg"
# 6. Trennen mit Widerruf
e2e_expect 1 "$(e2e_nc_tokens anna)" "vor dem Trennen genau ein Zugang"
code=$(e2e_status "$JAR" DELETE "$C/connect")
e2e_expect 200 "$code" "DELETE connect"
e2e_expect 0 "$(e2e_nc_tokens anna)" "nach dem Trennen kein Tessera-Zugang mehr"
code=$(e2e_status "$JAR" GET "$C/status")
e2e_expect 200 "$code" "GET status nach Trennen"
e2e_contains "$E2E_TMP/body.out" '"account":null' "status: account null"
echo "e2e connect ok"
@@ -82,3 +82,49 @@ e2e_second_user() {
e2e_expect 200 "$code" "Passwortwechsel $name"
e2e_login "$jar" "$name" "$final"
}
# --- Aufgabe 2: Konten -------------------------------------------------------------------
NC_OCC() { docker exec -u www-data tessera-nc-test php occ "$@"; }
# e2e_nc_tokens <nc-benutzer> — Anzahl der Tessera-Zugaenge (Name enthaelt "Tessera") in der
# Nextcloud. Messweg: occ user:auth-tokens:list --output=json, Zeilen mit "Tessera" im Namen.
e2e_nc_tokens() {
NC_OCC user:auth-tokens:list "$1" --output=json 2>/dev/null \
| python3 -I -c 'import json,sys
d=json.load(sys.stdin)
print(len([t for t in d if "Tessera" in str(t.get("name",""))]))'
}
# e2e_connect_anna <jar> — verbindet anna (Passwort-Weg) im Tessera-Benutzer des <jar>.
# Gibt den HTTP-Status aus; der Antwortkoerper landet in $E2E_TMP/body.out.
e2e_connect_anna() {
e2e_status "$1" POST "$API/modules/nextcloud-files/connect/password" \
'{"loginName":"anna","password":"User1-Pass-12345"}'
}
# e2e_wait_health — wartet bis die API antwortet.
e2e_wait_health() {
local i
for i in $(seq 1 60); do
curl -sf "$API/health" >/dev/null 2>&1 && return 0
sleep 2
done
e2e_fail "API wurde nicht gesund"
}
# e2e_grant_use <admin-jar> <benutzer-jar> [slug] — gibt dem Benutzer des zweiten <jar> die
# Freigabestufe Benutzen fuer das Modul (idempotent: ein vorhandener Grant ist in Ordnung).
e2e_grant_use() {
local admin_jar=$1 user_jar=$2 slug=${3:-nextcloud-files} code user_id module_id
code=$(e2e_status "$user_jar" GET "$API/auth/me" "" "$E2E_TMP/me.out")
e2e_expect 200 "$code" "auth/me"
user_id=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$E2E_TMP/me.out")
e2e_status "$admin_jar" GET "$API/modules/catalog" "" "$E2E_TMP/catalog.out" >/dev/null
module_id=$(python3 -I -c 'import json,sys
for m in json.load(open(sys.argv[2])):
if m["slug"]==sys.argv[1]: print(m["id"]); break' "$slug" "$E2E_TMP/catalog.out")
code=$(e2e_status "$admin_jar" POST "$API/module-grants" \
"{\"moduleId\":\"$module_id\",\"userId\":\"$user_id\",\"level\":\"USE\"}")
case "$code" in 200|201|409) ;; *) e2e_fail "Freigabe Benutzen -> $code" ;; esac
}
@@ -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) => {
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) => {
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) => {
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(['activate', 'deactivate'])(
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleRegistryController, 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);
});
});
@@ -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);
}
}
}
@@ -0,0 +1,98 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
disconnectNextcloud,
type NextcloudFilesAccount,
NextcloudFilesRequestError,
} from '@/lib/nextcloud-files-api';
/**
* Kontoleiste im Seitenkopf (quick-261008-mzu): zeigt, mit welchem Nextcloud-Konto
* der Benutzer angemeldet ist, und trennt die Verbindung nach einer Rueckfrage.
* Die API widerruft den Zugang bei Nextcloud; die Dateien dort bleiben unveraendert.
*/
export function AccountBar({
account,
host,
onDisconnected,
}: {
account: NextcloudFilesAccount;
host: string | null;
onDisconnected: () => void;
}) {
const t = useTranslations('nextcloudFiles.account');
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const name = account.displayName ?? account.ncUserId ?? '';
const disconnect = async () => {
setBusy(true);
setError(null);
try {
await disconnectNextcloud();
setConfirming(false);
onDisconnected();
} catch (err) {
// 409 notConnected: bereits getrennt, die Seite soll den Stand neu laden.
if (err instanceof NextcloudFilesRequestError && err.code === 'notConnected') {
setConfirming(false);
onDisconnected();
} else {
setError(t('disconnectFailed'));
}
} finally {
setBusy(false);
}
};
if (confirming) {
return (
<div
role="alertdialog"
aria-label={t('confirm.title')}
className="flex flex-wrap items-center justify-end gap-2"
>
<p className="max-w-md text-right text-sm text-foreground">
<span className="font-medium">{t('confirm.title')}</span> {t('confirm.body')}
</p>
{error && (
<p role="alert" className="w-full text-right text-sm text-destructive">
{error}
</p>
)}
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => setConfirming(false)}
>
{t('confirm.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void disconnect()}
>
{t('confirm.confirm')}
</button>
</div>
);
}
return (
<div className="flex flex-wrap items-center justify-end gap-3">
<div className="min-w-0 text-right">
<p className="truncate text-sm font-medium text-foreground">{t('signedInAs', { name })}</p>
{host && <p className="truncate text-xs text-muted-foreground">{host}</p>}
</div>
<button type="button" className="btn btn-secondary" onClick={() => setConfirming(true)}>
{t('disconnect')}
</button>
</div>
);
}
@@ -0,0 +1,332 @@
'use client';
import { useTranslations } from 'next-intl';
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import {
cancelLoginFlow,
connectWithPassword,
type NextcloudFilesFlowStart,
NextcloudFilesRequestError,
pollLoginFlow,
startLoginFlow,
} from '@/lib/nextcloud-files-api';
const POLL_INTERVAL_MS = 2000;
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
interface ErrorState {
code: string | null;
message: string;
retryAfterSeconds: number | null;
}
/**
* Verbindungsbildschirm (quick-261008-mzu, L-02/L-03): ein Benutzer verbindet sein
* eigenes Nextcloud-Konto. Zwei Wege:
* 1. Benutzername und Passwort. Tessera speichert das Passwort nicht, sondern
* laesst sich einen eigenen Zugang ausstellen.
* 2. "Im Browser anmelden" (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung.
* Der Link zur Nextcloud ist ein gewoehnlicher Verweis, den der Benutzer
* SELBST anklickt — nie ein per Skript nach einem asynchronen Aufruf
* geoeffnetes Fenster (Popup-Sperren; die Desktop-App oeffnet
* `target=_blank` ueber ihren Dokument-Helfer). Waehrenddessen fragt die
* Seite alle 2 s die API, die ihrerseits Nextcloud befragt.
*/
export function ConnectPanel({
host,
expired,
onConnected,
}: {
host: string | null;
expired: boolean;
onConnected: () => void;
}) {
const t = useTranslations('nextcloudFiles.connect');
const [loginName, setLoginName] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<ErrorState | null>(null);
const [flow, setFlow] = useState<NextcloudFilesFlowStart | null>(null);
const toError = useCallback(
(err: unknown): ErrorState => {
if (err instanceof NextcloudFilesRequestError) {
const retry = Number(err.extra.retryAfterSeconds);
return {
code: err.code,
message: err.message,
retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? retry : null,
};
}
return { code: null, message: t('errors.generic'), retryAfterSeconds: null };
},
[t],
);
const errorText = (state: ErrorState): string => {
const minutes = Math.max(1, Math.ceil((state.retryAfterSeconds ?? 60) / 60));
switch (state.code) {
case 'credentialsOrTwoFactor':
return t('errors.credentialsOrTwoFactor');
case 'useBrowserLogin':
return t('errors.useBrowserLogin');
case 'tooManyAttempts':
return t('errors.tooManyAttempts', { minutes });
case 'nextcloudLocked':
return t('errors.nextcloudLocked', { minutes });
case 'flowExpired':
return t('errors.flowExpired');
case 'nextcloudUnavailable':
return t('errors.nextcloudUnavailable');
case 'tooManyFlows':
return t('errors.tooManyFlows');
default:
return state.message || t('errors.generic');
}
};
// --- Passwort ----------------------------------------------------------------
const submit = async (event: FormEvent) => {
event.preventDefault();
if (busy) return;
const name = loginName.trim();
if (name === '' || password === '') return;
setBusy(true);
setError(null);
try {
await connectWithPassword({ loginName: name, password });
setPassword('');
onConnected();
} catch (err) {
setError(toError(err));
} finally {
// Das Passwort bleibt nach keinem Versuch im Formular stehen.
setPassword('');
setBusy(false);
}
};
// --- Browser-Anmeldung ----------------------------------------------------------
const flowRef = useRef<NextcloudFilesFlowStart | null>(null);
flowRef.current = flow;
// Stabile Verweise, damit die Abfrageschleife nicht bei jedem Neuaufbau der Seite neu startet.
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
const toErrorRef = useRef(toError);
toErrorRef.current = toError;
const begin = async () => {
if (starting || busy) return;
setStarting(true);
setError(null);
try {
setFlow(await startLoginFlow());
} catch (err) {
setError(toError(err));
} finally {
setStarting(false);
}
};
const cancel = async () => {
const current = flowRef.current;
setFlow(null);
if (current) {
try {
await cancelLoginFlow(current.flowId);
} catch {
// Der Ablauf ist serverseitig ohnehin begrenzt; ein Fehler beim Abbrechen ist unerheblich.
}
}
};
useEffect(() => {
if (!flow) return;
const flowId = flow.flowId;
let stopped = false;
let inFlight = false;
const stop = (next: ErrorState | null) => {
stopped = true;
setFlow(null);
if (next) setError(next);
};
const poll = async () => {
if (stopped || inFlight) return;
inFlight = true;
try {
const result = await pollLoginFlow(flowId);
if (stopped) return;
if (result.state === 'connected') {
stopped = true;
setFlow(null);
onConnectedRef.current();
} else if (result.state === 'failed') {
stop({ code: result.code, message: result.message, retryAfterSeconds: null });
}
} catch (err) {
if (stopped) return;
if (err instanceof NextcloudFilesRequestError) {
if (err.status === 404 || err.status === 410) {
// Abgelaufen oder unbekannt (z. B. nach einem Neustart der API): von vorn beginnen.
stop({ code: 'flowExpired', message: err.message, retryAfterSeconds: null });
} else if (err.code === 'nextcloudLocked' || (err.status < 500 && err.status !== 429)) {
stop(toErrorRef.current(err));
}
// Andere Serverfehler sind meist vorübergehend: beim nächsten Takt erneut versuchen.
}
// Netzwerkaussetzer: ebenfalls beim nächsten Takt erneut versuchen.
} finally {
inFlight = false;
}
};
const timer = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') void poll();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
stopped = true;
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [flow]);
// Beim Verlassen der Seite einen noch offenen Ablauf freigeben.
useEffect(() => {
return () => {
const open = flowRef.current;
if (open) void cancelLoginFlow(open.flowId).catch(() => undefined);
};
}, []);
const preferBrowser =
error?.code === 'credentialsOrTwoFactor' || error?.code === 'useBrowserLogin';
const primaryClass = 'btn btn-primary w-full justify-center';
const secondaryClass = 'btn btn-secondary w-full justify-center';
return (
<div data-testid="nextcloud-files-connect" className="mx-auto w-full max-w-lg">
<div className="surface space-y-5 p-6">
<div>
{host && <p className="text-xs font-medium text-muted-foreground">{host}</p>}
<h2 className="mt-0.5 text-lg font-semibold text-foreground">{t('title')}</h2>
</div>
{expired && (
<p
role="status"
className="rounded border border-status-warn/40 bg-status-warn/12 px-3 py-2 text-sm text-status-warn-fg"
>
{t('expiredNotice')}
</p>
)}
{flow ? (
<div className="space-y-4" data-testid="nextcloud-files-flow">
<p className="text-sm text-foreground">{t('waiting.instruction')}</p>
<a
href={flow.loginUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary w-full justify-center"
>
{t('waiting.open')}
</a>
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full bg-primary-strong motion-safe:animate-pulse"
/>
{t('waiting.status')}
</p>
<button type="button" className="btn btn-subtle" onClick={() => void cancel()}>
{t('waiting.cancel')}
</button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">{t('explanation')}</p>
<form onSubmit={(e) => void submit(e)} className="space-y-4">
<div className="space-y-1.5">
<label
htmlFor="nc-files-login-name"
className="block text-sm font-medium text-foreground"
>
{t('loginName')}
</label>
<input
id="nc-files-login-name"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
value={loginName}
onChange={(e) => setLoginName(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="nc-files-password"
className="block text-sm font-medium text-foreground"
>
{t('password')}
</label>
<input
id="nc-files-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{errorText(error)}
</p>
)}
<button
type="submit"
className={preferBrowser ? secondaryClass : primaryClass}
disabled={busy || loginName.trim() === '' || password === ''}
>
{busy ? t('submitting') : t('submit')}
</button>
</form>
<div
className="flex items-center gap-3 text-xs text-muted-foreground"
aria-hidden="true"
>
<span className="h-px flex-1 bg-border" />
{t('or')}
<span className="h-px flex-1 bg-border" />
</div>
<div className="space-y-1.5">
<button
type="button"
className={preferBrowser ? primaryClass : secondaryClass}
disabled={starting || busy}
onClick={() => void begin()}
>
{starting ? t('starting') : t('browser')}
</button>
<p className="text-center text-xs text-muted-foreground">{t('browserHint')}</p>
</div>
</>
)}
</div>
</div>
);
}
@@ -1,8 +1,20 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import {
act,
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudFilesSettings, NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
import {
type NextcloudFilesAccount,
NextcloudFilesRequestError,
type NextcloudFilesSettings,
type NextcloudFilesStatus,
} from '@/lib/nextcloud-files-api';
import de from '@/messages/de.json';
import NextcloudFilesPage from './page';
@@ -18,6 +30,11 @@ const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestSettings = vi.fn();
const mockConnect = vi.fn();
const mockStartFlow = vi.fn();
const mockPollFlow = vi.fn();
const mockCancelFlow = vi.fn();
const mockDisconnect = vi.fn();
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
@@ -27,6 +44,11 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args),
saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args),
testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...args),
connectWithPassword: (...args: unknown[]) => mockConnect(...args),
startLoginFlow: (...args: unknown[]) => mockStartFlow(...args),
pollLoginFlow: (...args: unknown[]) => mockPollFlow(...args),
cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args),
disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args),
};
});
@@ -55,6 +77,11 @@ beforeEach(() => {
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset();
mockTestSettings.mockReset();
mockConnect.mockReset();
mockStartFlow.mockReset();
mockPollFlow.mockReset();
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
mockDisconnect.mockReset();
});
afterEach(() => cleanup());
@@ -196,3 +223,246 @@ describe('SettingsTab', () => {
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
});
});
// --- Aufgabe 2: Verbinden, Browser-Anmeldung, Abmelden ------------------------------------
function account(over: Partial<NextcloudFilesAccount> = {}): NextcloudFilesAccount {
return {
connected: true,
expired: false,
status: 'ACTIVE',
ncUserId: 'anna',
displayName: 'Anna Müller',
connectedVia: 'PASSWORD',
connectedAt: '2026-10-08T10:00:00.000Z',
...over,
};
}
const LOGIN_URL = 'http://cloud.example/index.php/login/v2/flow/abc';
describe('ConnectPanel', () => {
it('eingerichtet ohne Konto: Verbindungsbildschirm mit Feldern, Anmelden und "Im Browser anmelden"', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByLabelText('Benutzername oder E-Mail')).toBeTruthy();
expect(screen.getByLabelText('Passwort')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Anmelden' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy();
expect(screen.queryByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeNull();
});
it('Anmelden ruft die API einmal mit dem getrimmten Namen auf, leert das Passwort und laedt den Stand neu', async () => {
mockConnect.mockResolvedValue(status({ account: account() }));
render(<NextcloudFilesPage />);
const name = await screen.findByLabelText('Benutzername oder E-Mail');
const pass = screen.getByLabelText('Passwort') as HTMLInputElement;
fireEvent.change(name, { target: { value: ' anna ' } });
fireEvent.change(pass, { target: { value: 'geheim' } });
mockGetStatus.mockResolvedValue(status({ account: account() }));
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
await waitFor(() => expect(mockConnect).toHaveBeenCalledTimes(1));
expect(mockConnect).toHaveBeenCalledWith({ loginName: 'anna', password: 'geheim' });
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
expect(screen.queryByLabelText('Passwort')).toBeNull();
});
it('nach einem Fehlschlag ist das Passwortfeld leer', async () => {
mockConnect.mockRejectedValue(
new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'),
);
render(<NextcloudFilesPage />);
const pass = (await screen.findByLabelText('Passwort')) as HTMLInputElement;
fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), {
target: { value: 'zoe' },
});
fireEvent.change(pass, { target: { value: 'falsch' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
await screen.findByRole('alert');
expect((screen.getByLabelText('Passwort') as HTMLInputElement).value).toBe('');
});
it('credentialsOrTwoFactor erklaert und macht "Im Browser anmelden" zur Hauptaktion', async () => {
mockConnect.mockRejectedValue(
new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'),
);
render(<NextcloudFilesPage />);
const browser = await screen.findByRole('button', { name: 'Im Browser anmelden' });
expect(browser.className).toContain('btn-secondary');
fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), {
target: { value: 'zoe' },
});
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText(/Zwei-Faktor-Anmeldung/, { selector: 'p[role=alert]' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Im Browser anmelden' }).className).toContain(
'btn-primary',
);
});
it('tooManyAttempts zeigt die Wartezeit in Minuten, nextcloudLocked einen eigenen Text', async () => {
mockConnect.mockRejectedValueOnce(
new NextcloudFilesRequestError(429, 'tooManyAttempts', 'x', { retryAfterSeconds: 840 }),
);
render(<NextcloudFilesPage />);
fireEvent.change(await screen.findByLabelText('Benutzername oder E-Mail'), {
target: { value: 'anna' },
});
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText('Zu viele Fehlversuche. Bitte warten Sie 14 Minuten.'),
).toBeTruthy();
mockConnect.mockRejectedValueOnce(
new NextcloudFilesRequestError(503, 'nextcloudLocked', 'x', { retryAfterSeconds: 600 }),
);
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText(/Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend/),
).toBeTruthy();
expect(screen.getByText(/10 Minuten erneut/)).toBeTruthy();
});
it('abgelaufenes Konto: Hinweis ueber dem Formular', async () => {
mockGetStatus.mockResolvedValue(
status({ account: account({ connected: false, expired: true, status: 'EXPIRED' }) }),
);
render(<NextcloudFilesPage />);
expect(await screen.findByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeTruthy();
expect(screen.getByLabelText('Passwort')).toBeTruthy();
});
});
describe('Browser-Anmeldung', () => {
let openSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
mockStartFlow.mockResolvedValue({
flowId: 'f1',
loginUrl: LOGIN_URL,
expiresAt: '2026-10-08T12:00:00.000Z',
});
mockPollFlow.mockResolvedValue({ state: 'pending' });
});
afterEach(() => {
vi.useRealTimers();
openSpy.mockRestore();
});
async function startFlow() {
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Im Browser anmelden' }));
return screen.findByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' });
}
it('startet den Ablauf einmal und zeigt einen echten Verweis (target _blank), ohne window.open', async () => {
const link = (await startFlow()) as HTMLAnchorElement;
expect(mockStartFlow).toHaveBeenCalledTimes(1);
expect(link.getAttribute('href')).toBe(LOGIN_URL);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
expect(screen.getByText('Warten auf Bestätigung in Nextcloud …')).toBeTruthy();
expect(openSpy).not.toHaveBeenCalled();
});
it('fragt alle 2000 ms ab und stoppt bei "connected" (Stand wird neu geladen)', async () => {
await startFlow();
expect(mockPollFlow).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(mockPollFlow).toHaveBeenCalledTimes(1);
expect(mockPollFlow).toHaveBeenCalledWith('f1');
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(mockPollFlow).toHaveBeenCalledTimes(2);
mockPollFlow.mockResolvedValue({ state: 'connected' });
mockGetStatus.mockResolvedValue(
status({ account: account({ connectedVia: 'LOGIN_FLOW', displayName: 'Zwei Faktor' }) }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(await screen.findByText('Angemeldet als Zwei Faktor')).toBeTruthy();
const after = mockPollFlow.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(mockPollFlow.mock.calls.length).toBe(after);
});
it('Abbrechen ruft cancelLoginFlow auf und beendet die Abfrage', async () => {
await startFlow();
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
await waitFor(() => expect(mockCancelFlow).toHaveBeenCalledWith('f1'));
expect(await screen.findByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(mockPollFlow).not.toHaveBeenCalled();
});
it('flowExpired (410) beendet die Abfrage mit Hinweis', async () => {
await startFlow();
mockPollFlow.mockRejectedValue(new NextcloudFilesRequestError(410, 'flowExpired', 'x'));
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(
await screen.findByText('Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.'),
).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' })).toBeNull();
});
it('"failed" beendet die Abfrage und zeigt den Text', async () => {
await startFlow();
mockPollFlow.mockResolvedValue({
state: 'failed',
code: 'nextcloudUnavailable',
message: 'm',
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(await screen.findByText(/Nextcloud ist nicht erreichbar/)).toBeTruthy();
});
});
describe('AccountBar', () => {
beforeEach(() => {
mockGetStatus.mockResolvedValue(status({ account: account() }));
});
it('zeigt "Angemeldet als ..." und den Host', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
expect(screen.getByText('cloud.example')).toBeTruthy();
expect(screen.getByTestId('nextcloud-files-browser')).toBeTruthy();
});
it('Abmelden fragt nach und ruft erst nach "Trennen" disconnectNextcloud auf', async () => {
mockDisconnect.mockResolvedValue({ disconnected: true });
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' }));
expect(mockDisconnect).not.toHaveBeenCalled();
expect(screen.getByText(/Tessera vergisst den Zugang/)).toBeTruthy();
mockGetStatus.mockResolvedValue(status());
fireEvent.click(screen.getByRole('button', { name: 'Trennen' }));
await waitFor(() => expect(mockDisconnect).toHaveBeenCalledTimes(1));
expect(await screen.findByLabelText('Passwort')).toBeTruthy();
});
it('Abbrechen in der Rueckfrage trennt nichts', async () => {
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' }));
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
expect(mockDisconnect).not.toHaveBeenCalled();
expect(screen.getByText('Angemeldet als Anna Müller')).toBeTruthy();
});
});
@@ -7,6 +7,8 @@ import { SettingsSection } from '@/components/control-center/settings-section';
import { PageHeader } from '@/components/layout/page-header';
import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountBar } from './components/AccountBar';
import { ConnectPanel } from './components/ConnectPanel';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'files' | 'settings';
@@ -15,9 +17,10 @@ type TabId = 'files' | 'settings';
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Der
* Abschnitt `nextcloud-files-main` ist die Stelle, an der das Verbinden des
* eigenen Kontos und der Dateibrowser eingehaengt werden.
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im
* Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto
* (ConnectPanel); ist es verbunden, steht dort der Dateibereich
* (`nextcloud-files-browser`, den die Dateiansicht fuellt).
*/
export default function NextcloudFilesPage() {
const t = useTranslations('nextcloudFiles');
@@ -40,13 +43,25 @@ export default function NextcloudFilesPage() {
void reloadStatus();
}, [reloadStatus]);
const account = status?.account ?? null;
const connected = account?.connected === true;
const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files';
return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-files" title={t('title')} description={t('description')} />
<PageHeader
moduleSlug="nextcloud-files"
title={t('title')}
description={t('description')}
actions={
activeTab === 'files' && status?.configured && account && connected ? (
<AccountBar account={account} host={status.host} onDisconnected={reloadStatus} />
) : undefined
}
/>
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
@@ -71,9 +86,19 @@ export default function NextcloudFilesPage() {
)}
{activeTab === 'files' && status?.configured && (
<section data-testid="nextcloud-files-main" className="space-y-4">
{connected ? (
<div data-testid="nextcloud-files-browser">
<p className="text-sm text-muted-foreground">
{t('main.server', { host: status.host ?? '' })}
</p>
</div>
) : (
<ConnectPanel
host={status.host}
expired={account?.expired === true}
onConnected={reloadStatus}
/>
)}
</section>
)}
{activeTab === 'settings' && canManage && (
+45
View File
@@ -12,11 +12,25 @@ const BASE = '/modules/nextcloud-files';
export interface NextcloudFilesAccount {
connected: boolean;
expired: boolean;
status: 'ACTIVE' | 'EXPIRED';
ncUserId: string | null;
displayName: string | null;
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
connectedAt: string | null;
}
export interface NextcloudFilesFlowStart {
flowId: string;
/** Link zur Anmeldeseite der Nextcloud; der Benutzer oeffnet ihn mit eigenem Klick. */
loginUrl: string;
expiresAt: string;
}
export type NextcloudFilesFlowPoll =
| { state: 'pending' }
| { state: 'connected' }
| { state: 'failed'; code: string; message: string };
export interface NextcloudFilesStatus {
configured: boolean;
serverUrl: string | null;
@@ -118,3 +132,34 @@ export function saveNextcloudFilesSettings(input: {
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
}
/**
* Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die
* API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis.
*/
export function connectWithPassword(input: {
loginName: string;
password: string;
}): Promise<NextcloudFilesStatus> {
return request<NextcloudFilesStatus>('/connect/password', { method: 'POST', json: input });
}
/** Startet die Browser-Anmeldung (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. */
export function startLoginFlow(): Promise<NextcloudFilesFlowStart> {
return request<NextcloudFilesFlowStart>('/connect/flow', { method: 'POST' });
}
export function pollLoginFlow(flowId: string): Promise<NextcloudFilesFlowPoll> {
return request<NextcloudFilesFlowPoll>(`/connect/flow/${encodeURIComponent(flowId)}`);
}
export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> {
return request<{ cancelled: true }>(`/connect/flow/${encodeURIComponent(flowId)}`, {
method: 'DELETE',
});
}
/** Trennt die Verbindung; die API widerruft den Zugang bei Nextcloud. */
export function disconnectNextcloud(): Promise<{ disconnected: true }> {
return request<{ disconnected: true }>('/connect', { method: 'DELETE' });
}
+40
View File
@@ -2337,6 +2337,46 @@
"main": {
"server": "Nextcloud: {host}"
},
"connect": {
"title": "Mit Nextcloud verbinden",
"expiredNotice": "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an.",
"explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.",
"loginName": "Benutzername oder E-Mail",
"password": "Passwort",
"submit": "Anmelden",
"submitting": "Melde an …",
"or": "oder",
"browser": "Im Browser anmelden",
"starting": "Starte …",
"browserHint": "Für Konten mit Zwei-Faktor-Anmeldung",
"waiting": {
"instruction": "Öffnen Sie die Anmeldung bei Nextcloud, melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“.",
"open": "Anmeldung bei Nextcloud öffnen",
"status": "Warten auf Bestätigung in Nextcloud …",
"cancel": "Abbrechen"
},
"errors": {
"generic": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"credentialsOrTwoFactor": "Die Anmeldung hat nicht geklappt. Entweder stimmen Benutzername oder Passwort nicht, oder Ihr Konto nutzt die Zwei-Faktor-Anmeldung. Dann melden Sie sich bitte im Browser an.",
"useBrowserLogin": "Nextcloud erlaubt für dieses Konto keine Anmeldung mit Passwort. Bitte melden Sie sich im Browser an.",
"tooManyAttempts": "Zu viele Fehlversuche. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.",
"nextcloudLocked": "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes, plural, one {# Minute} other {# Minuten}} erneut.",
"flowExpired": "Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.",
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
"tooManyFlows": "Zurzeit laufen zu viele Anmeldungen im Browser. Bitte versuchen Sie es gleich erneut."
}
},
"account": {
"signedInAs": "Angemeldet als {name}",
"disconnect": "Abmelden",
"disconnectFailed": "Die Verbindung konnte nicht getrennt werden. Bitte versuchen Sie es erneut.",
"confirm": {
"title": "Verbindung zu Nextcloud trennen?",
"body": "Tessera vergisst den Zugang. Ihre Dateien in Nextcloud bleiben unverändert.",
"confirm": "Trennen",
"cancel": "Abbrechen"
}
},
"errors": {
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden."
+40
View File
@@ -2337,6 +2337,46 @@
"main": {
"server": "Nextcloud: {host}"
},
"connect": {
"title": "Connect to Nextcloud",
"expiredNotice": "Your connection to Nextcloud has expired or was revoked in Nextcloud. Please sign in again.",
"explanation": "Sign in with your Nextcloud account. Tessera does not store your password; instead it has Nextcloud issue a separate access that you can revoke at any time.",
"loginName": "User name or email",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in …",
"or": "or",
"browser": "Sign in with the browser",
"starting": "Starting …",
"browserHint": "For accounts with two-factor authentication",
"waiting": {
"instruction": "Open the Nextcloud sign-in, log in there and confirm with “Grant access”.",
"open": "Open sign-in at Nextcloud",
"status": "Waiting for confirmation in Nextcloud …",
"cancel": "Cancel"
},
"errors": {
"generic": "Signing in failed. Please try again.",
"credentialsOrTwoFactor": "Signing in did not work. Either the user name or password is wrong, or your account uses two-factor authentication. In that case, please sign in with the browser.",
"useBrowserLogin": "Nextcloud does not allow password sign-in for this account. Please sign in with the browser.",
"tooManyAttempts": "Too many failed attempts. Please wait {minutes, plural, one {# minute} other {# minutes}}.",
"nextcloudLocked": "Nextcloud is temporarily blocking requests from the Tessera server. Please try again in {minutes, plural, one {# minute} other {# minutes}}.",
"flowExpired": "The sign-in has expired. Please start it again.",
"nextcloudUnavailable": "Nextcloud is unreachable or did not answer in time.",
"tooManyFlows": "Too many browser sign-ins are running right now. Please try again in a moment."
}
},
"account": {
"signedInAs": "Signed in as {name}",
"disconnect": "Sign out",
"disconnectFailed": "The connection could not be removed. Please try again.",
"confirm": {
"title": "Disconnect from Nextcloud?",
"body": "Tessera forgets the access. Your files in Nextcloud stay unchanged.",
"confirm": "Disconnect",
"cancel": "Cancel"
}
},
"errors": {
"request": "The request failed. Please try again.",
"loadStatus": "The state of the Nextcloud connection could not be loaded."
@@ -236,4 +236,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'dass',
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
'zuverlässig',
// quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“
'ausstellen',
'vergisst',
];
File diff suppressed because one or more lines are too long