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:
+115
@@ -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_expect 200 "$code" "Passwortwechsel $name"
|
||||||
e2e_login "$jar" "$name" "$final"
|
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;
|
||||||
@@ -977,6 +977,8 @@ model NextcloudFilesAccount {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
baseUrl String
|
baseUrl String
|
||||||
ncUserId String
|
ncUserId String
|
||||||
|
// Anmeldename, mit dem das App-Passwort ausgestellt wurde (Basic-Benutzer). NULL = ncUserId.
|
||||||
|
ncLoginName String?
|
||||||
ncDisplayName String?
|
ncDisplayName String?
|
||||||
encryptedAppPassword String
|
encryptedAppPassword String
|
||||||
status NextcloudFilesAccountStatus @default(ACTIVE)
|
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 { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
|
||||||
import { ProxmoxController } from '../proxmox/proxmox.controller';
|
import { ProxmoxController } from '../proxmox/proxmox.controller';
|
||||||
import { TendersController } from '../tenders/tenders.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 { 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):
|
* 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_SLUG_KEY, DkvController)).toBe('dkv-fleet');
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
|
||||||
expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard);
|
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);
|
expect(names.length).toBe(11);
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])(
|
it.each([
|
||||||
'ProxmoxController.%s verlangt Verwalten für proxmox',
|
'create',
|
||||||
(name) => {
|
'update',
|
||||||
|
'remove',
|
||||||
|
'poll',
|
||||||
|
'test',
|
||||||
|
'testDraft',
|
||||||
|
])('ProxmoxController.%s verlangt Verwalten für proxmox', (name) => {
|
||||||
expectManage(ProxmoxController, name, 'proxmox');
|
expectManage(ProxmoxController, name, 'proxmox');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
|
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
|
||||||
const fn = handler(ProxmoxController, 'list');
|
const fn = handler(ProxmoxController, 'list');
|
||||||
@@ -73,14 +79,25 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
|||||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
|
it.each([
|
||||||
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
|
'create',
|
||||||
(name) => {
|
'update',
|
||||||
|
'remove',
|
||||||
|
'checkAll',
|
||||||
|
'checkOne',
|
||||||
|
'uploadLogo',
|
||||||
|
'removeLogo',
|
||||||
|
])('NextcloudStatusController.%s verlangt Verwalten für nextcloud-status', (name) => {
|
||||||
expectManage(NextcloudStatusController, name, 'nextcloud-status');
|
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);
|
const fn = handler(NextcloudStatusController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||||
expect(Reflect.getMetadata(ROLES_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.
|
// Spätere Aufgaben von quick-261008-mzu ergänzen diese Liste um ihre Benutzen-Handler.
|
||||||
it.each([
|
it.each([
|
||||||
'getStatus',
|
'getStatus',
|
||||||
|
'connectPassword',
|
||||||
|
'startFlow',
|
||||||
|
'pollFlow',
|
||||||
|
'cancelFlow',
|
||||||
|
'disconnect',
|
||||||
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||||
const fn = handler(NextcloudFilesController, name);
|
const fn = handler(NextcloudFilesController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
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)', () => {
|
describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
|
||||||
it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])(
|
it.each([
|
||||||
'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
|
'getSourceConfig',
|
||||||
(name) => {
|
'saveSourceConfig',
|
||||||
|
'pollNow',
|
||||||
|
])('TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
|
||||||
expectAdminOnly(TendersController, name);
|
expectAdminOnly(TendersController, name);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['matrix', 'userAccess', 'create', 'remove'])(
|
it.each([
|
||||||
'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
|
'matrix',
|
||||||
(name) => {
|
'userAccess',
|
||||||
|
'create',
|
||||||
|
'remove',
|
||||||
|
])('ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
|
||||||
expectAdminOnly(ModuleGrantsController, name);
|
expectAdminOnly(ModuleGrantsController, name);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['activate', 'deactivate'])(
|
it.each([
|
||||||
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
|
'activate',
|
||||||
(name) => {
|
'deactivate',
|
||||||
|
])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
|
||||||
expectAdminOnly(ModuleRegistryController, 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('getSettings')).toEqual([0, 'settings']);
|
||||||
expect(route('saveSettings')).toEqual([2, 'settings']);
|
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||||
expect(route('testSettings')).toEqual([1, 'settings/test']);
|
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', () => {
|
it('POST settings/test antwortet 200, nicht 201', () => {
|
||||||
@@ -87,34 +115,85 @@ describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Paramete
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('NextcloudFilesController — Delegation', () => {
|
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() {
|
function makeSettings() {
|
||||||
return {
|
return {
|
||||||
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
|
|
||||||
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
|
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
|
||||||
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
|
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 settings = makeSettings();
|
||||||
const controller = new NextcloudFilesController(settings as any);
|
const account = makeAccount();
|
||||||
await controller.getStatus(req('t1'));
|
const controller = new NextcloudFilesController(settings as any, account as any);
|
||||||
|
await controller.getStatus(userReq('t1', 'u1'));
|
||||||
await controller.getSettings(req('t1'));
|
await controller.getSettings(req('t1'));
|
||||||
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
||||||
await controller.testSettings(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.getSettings).toHaveBeenCalledWith('t1');
|
||||||
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
|
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
|
||||||
expect(settings.testAddress).toHaveBeenCalledWith('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 () => {
|
it('ohne Mandantenkontext: ForbiddenException', async () => {
|
||||||
const controller = new NextcloudFilesController(makeSettings() as any);
|
const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any);
|
||||||
await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(
|
await expect(
|
||||||
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
|
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).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 {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||||
|
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
|
||||||
import {
|
import {
|
||||||
SaveNextcloudFilesSettingsDto,
|
SaveNextcloudFilesSettingsDto,
|
||||||
TestNextcloudFilesSettingsDto,
|
TestNextcloudFilesSettingsDto,
|
||||||
} from './dto/nextcloud-files-settings.dto';
|
} from './dto/nextcloud-files-settings.dto';
|
||||||
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +55,10 @@ import { NextcloudFilesSettingsService } from './nextcloud-files-settings.servic
|
|||||||
@Controller('modules/nextcloud-files')
|
@Controller('modules/nextcloud-files')
|
||||||
@UseModule('nextcloud-files')
|
@UseModule('nextcloud-files')
|
||||||
export class NextcloudFilesController {
|
export class NextcloudFilesController {
|
||||||
constructor(private readonly settings: NextcloudFilesSettingsService) {}
|
constructor(
|
||||||
|
private readonly settings: NextcloudFilesSettingsService,
|
||||||
|
private readonly account: NextcloudFilesAccountService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private requireTenantId(req: AuthenticatedRequest): string {
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
const tenantId = req.tenantId;
|
const tenantId = req.tenantId;
|
||||||
@@ -60,9 +68,18 @@ export class NextcloudFilesController {
|
|||||||
return tenantId;
|
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')
|
@Get('status')
|
||||||
async getStatus(@Req() req: AuthenticatedRequest) {
|
async getStatus(@Req() req: AuthenticatedRequest) {
|
||||||
return this.settings.getStatus(this.requireTenantId(req));
|
return this.account.getStatus(this.requireTenantId(req), this.requireUserId(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('settings')
|
@Get('settings')
|
||||||
@@ -84,4 +101,46 @@ export class NextcloudFilesController {
|
|||||||
this.requireTenantId(req);
|
this.requireTenantId(req);
|
||||||
return this.settings.testAddress(dto.baseUrl);
|
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 { NextcloudCallGate } from './nextcloud-call-gate';
|
||||||
import { NextcloudFilesController } from './nextcloud-files.controller';
|
import { NextcloudFilesController } from './nextcloud-files.controller';
|
||||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||||
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import {
|
import {
|
||||||
defaultStatusFetcher,
|
defaultStatusFetcher,
|
||||||
NEXTCLOUD_STATUS_FETCHER,
|
NEXTCLOUD_STATUS_FETCHER,
|
||||||
NextcloudFilesSettingsService,
|
NextcloudFilesSettingsService,
|
||||||
} from './nextcloud-files-settings.service';
|
} from './nextcloud-files-settings.service';
|
||||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
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
|
* Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in
|
||||||
@@ -23,11 +25,19 @@ import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
|||||||
controllers: [NextcloudFilesController],
|
controllers: [NextcloudFilesController],
|
||||||
providers: [
|
providers: [
|
||||||
NextcloudFilesSettingsService,
|
NextcloudFilesSettingsService,
|
||||||
|
NextcloudFilesAccountService,
|
||||||
|
NextcloudLoginGuard,
|
||||||
|
LoginFlowStore,
|
||||||
NextcloudCallGate,
|
NextcloudCallGate,
|
||||||
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
|
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
|
||||||
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
|
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
|
||||||
],
|
],
|
||||||
exports: [NextcloudCallGate, NextcloudFilesSettingsService, NEXTCLOUD_TRANSPORT],
|
exports: [
|
||||||
|
NextcloudCallGate,
|
||||||
|
NextcloudFilesSettingsService,
|
||||||
|
NextcloudFilesAccountService,
|
||||||
|
NEXTCLOUD_TRANSPORT,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class NextcloudFilesModule implements OnModuleInit {
|
export class NextcloudFilesModule implements OnModuleInit {
|
||||||
private readonly logger = new Logger(NextcloudFilesModule.name);
|
private readonly logger = new Logger(NextcloudFilesModule.name);
|
||||||
|
|||||||
@@ -224,9 +224,13 @@ export interface NextcloudFilesAccountView {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
/** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */
|
/** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
|
/** `ACTIVE`, oder `EXPIRED` bei gespeichertem Ablauf, anderer Adresse oder totem Zugangsschluessel. */
|
||||||
|
status: 'ACTIVE' | 'EXPIRED';
|
||||||
ncUserId: string | null;
|
ncUserId: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
|
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
|
||||||
|
/** Zeitpunkt der Verbindung (ISO), nie ein Geheimnis. */
|
||||||
|
connectedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextcloudFilesStatusView {
|
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 { NextIntlClientProvider } from 'next-intl';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 de from '@/messages/de.json';
|
||||||
import NextcloudFilesPage from './page';
|
import NextcloudFilesPage from './page';
|
||||||
|
|
||||||
@@ -18,6 +30,11 @@ const mockGetStatus = vi.fn();
|
|||||||
const mockGetSettings = vi.fn();
|
const mockGetSettings = vi.fn();
|
||||||
const mockSaveSettings = vi.fn();
|
const mockSaveSettings = vi.fn();
|
||||||
const mockTestSettings = 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) => {
|
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
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),
|
getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args),
|
||||||
saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args),
|
saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args),
|
||||||
testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...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());
|
mockGetSettings.mockReset().mockResolvedValue(settings());
|
||||||
mockSaveSettings.mockReset();
|
mockSaveSettings.mockReset();
|
||||||
mockTestSettings.mockReset();
|
mockTestSettings.mockReset();
|
||||||
|
mockConnect.mockReset();
|
||||||
|
mockStartFlow.mockReset();
|
||||||
|
mockPollFlow.mockReset();
|
||||||
|
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
|
||||||
|
mockDisconnect.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
@@ -196,3 +223,246 @@ describe('SettingsTab', () => {
|
|||||||
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
|
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 { PageHeader } from '@/components/layout/page-header';
|
||||||
import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
|
import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
|
||||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||||
|
import { AccountBar } from './components/AccountBar';
|
||||||
|
import { ConnectPanel } from './components/ConnectPanel';
|
||||||
import { SettingsTab } from './components/SettingsTab';
|
import { SettingsTab } from './components/SettingsTab';
|
||||||
|
|
||||||
type TabId = 'files' | 'settings';
|
type TabId = 'files' | 'settings';
|
||||||
@@ -15,9 +17,10 @@ type TabId = 'files' | 'settings';
|
|||||||
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
|
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
|
||||||
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der
|
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der
|
||||||
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur
|
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur
|
||||||
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Der
|
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im
|
||||||
* Abschnitt `nextcloud-files-main` ist die Stelle, an der das Verbinden des
|
* Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto
|
||||||
* eigenen Kontos und der Dateibrowser eingehaengt werden.
|
* (ConnectPanel); ist es verbunden, steht dort der Dateibereich
|
||||||
|
* (`nextcloud-files-browser`, den die Dateiansicht fuellt).
|
||||||
*/
|
*/
|
||||||
export default function NextcloudFilesPage() {
|
export default function NextcloudFilesPage() {
|
||||||
const t = useTranslations('nextcloudFiles');
|
const t = useTranslations('nextcloudFiles');
|
||||||
@@ -40,13 +43,25 @@ export default function NextcloudFilesPage() {
|
|||||||
void reloadStatus();
|
void reloadStatus();
|
||||||
}, [reloadStatus]);
|
}, [reloadStatus]);
|
||||||
|
|
||||||
|
const account = status?.account ?? null;
|
||||||
|
const connected = account?.connected === true;
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }];
|
const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }];
|
||||||
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||||
const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files';
|
const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
<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 && (
|
{statusError && (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
{t('errors.loadStatus')}
|
{t('errors.loadStatus')}
|
||||||
@@ -71,9 +86,19 @@ export default function NextcloudFilesPage() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'files' && status?.configured && (
|
{activeTab === 'files' && status?.configured && (
|
||||||
<section data-testid="nextcloud-files-main" className="space-y-4">
|
<section data-testid="nextcloud-files-main" className="space-y-4">
|
||||||
|
{connected ? (
|
||||||
|
<div data-testid="nextcloud-files-browser">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('main.server', { host: status.host ?? '' })}
|
{t('main.server', { host: status.host ?? '' })}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ConnectPanel
|
||||||
|
host={status.host}
|
||||||
|
expired={account?.expired === true}
|
||||||
|
onConnected={reloadStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'settings' && canManage && (
|
{activeTab === 'settings' && canManage && (
|
||||||
|
|||||||
@@ -12,11 +12,25 @@ const BASE = '/modules/nextcloud-files';
|
|||||||
export interface NextcloudFilesAccount {
|
export interface NextcloudFilesAccount {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
|
status: 'ACTIVE' | 'EXPIRED';
|
||||||
ncUserId: string | null;
|
ncUserId: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | 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 {
|
export interface NextcloudFilesStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
@@ -118,3 +132,34 @@ export function saveNextcloudFilesSettings(input: {
|
|||||||
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
|
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
|
||||||
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
|
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' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -2337,6 +2337,46 @@
|
|||||||
"main": {
|
"main": {
|
||||||
"server": "Nextcloud: {host}"
|
"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": {
|
"errors": {
|
||||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
"loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden."
|
"loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden."
|
||||||
|
|||||||
@@ -2337,6 +2337,46 @@
|
|||||||
"main": {
|
"main": {
|
||||||
"server": "Nextcloud: {host}"
|
"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": {
|
"errors": {
|
||||||
"request": "The request failed. Please try again.",
|
"request": "The request failed. Please try again.",
|
||||||
"loadStatus": "The state of the Nextcloud connection could not be loaded."
|
"loadStatus": "The state of the Nextcloud connection could not be loaded."
|
||||||
|
|||||||
@@ -236,4 +236,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'dass',
|
'dass',
|
||||||
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
|
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
|
||||||
'zuverlässig',
|
'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
Reference in New Issue
Block a user