From 9111eeeb91190ccfba7f3d928ba8b21e57fd9942 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 12 Aug 2026 08:39:44 +0200 Subject: [PATCH] Online-Banking: Einrichtung in zwei Schritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Bank lässt sich erst nach ihren TAN-Verfahren fragen, wenn die Zugangsdaten gespeichert sind. Das Anlegen führt deshalb über „Weiter“ direkt auf die Verfahrensseite, und die holt die Liste selbst – ein Knopf davor wäre ein Schritt, dessen Ergebnis feststeht. Scheitert die Abfrage, bleibt das Ergebnis in der Sitzung, damit nicht jeder Seitenaufruf wieder in dieselbe Zeitüberschreitung läuft; die ist für die Abfrage kürzer gesetzt als in der Bibliothek. Co-Authored-By: Claude Opus 5 --- Changes.md | 15 ++++- docs/banking.md | 38 ++++++++--- public/static/app.css | 5 +- src/Controllers/BankingController.php | 96 +++++++++++++++++++-------- src/Services/FinTsVerfahren.php | 6 ++ src/Views/banking/formular.php | 15 ++++- src/Views/banking/verfahren.php | 96 +++++++++++++++++---------- tests/e2e/specs/module.spec.js | 30 +++++---- 8 files changed, 207 insertions(+), 94 deletions(-) diff --git a/Changes.md b/Changes.md index 1823aef..027233c 100644 --- a/Changes.md +++ b/Changes.md @@ -60,9 +60,18 @@ Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis z - Umsätze, die vor einer TAN-Unterbrechung geschrieben wurden, bleiben erhalten. **Wahl des TAN-Verfahrens** (`Services\FinTsVerfahren`, Migration `009_tan_verfahren.sql`) -- Eigene Seite am Zugang (`/banking/{id}/verfahren`): Der Knopf *Verfahren bei der Bank - abfragen* holt die Liste über `getTanModes()` – das kommt ohne Anmeldung aus und verlangt - selbst keine TAN. TAN-Medien werden nur für die Verfahren geholt, die eines brauchen. +- **Einrichtung in zwei Schritten**: Zugangsdaten erfassen und mit *Weiter* speichern, dann + das TAN-Verfahren. Vorher lässt sich die Bank nicht fragen – ohne gespeicherte + Zugangsdaten gibt es keine Verbindung. +- Der zweite Schritt holt die Liste beim Aufruf selbst (`getTanModes()`, ohne Anmeldung und + ohne TAN); ein Knopf davor wäre ein Schritt, dessen Ergebnis feststeht. Ein erneuter + Anlauf steht daneben. TAN-Medien werden nur für die Verfahren geholt, die eines brauchen. +- Das Ergebnis liegt in der Sitzung, auch ein gescheitertes – sonst liefe jeder + Seitenaufruf wieder in dieselbe Zeitüberschreitung. Die sind für die Abfrage kürzer + gesetzt als in der Bibliothek (8/20 statt 15/30 Sekunden), weil sie an einem + Seitenaufruf hängt. +- Scheitert die Abfrage, erklärt die Seite, wo der Fehler meist steckt, und verlinkt zurück + auf die Zugangsdaten; überspringen lässt sich der Schritt ebenfalls. - Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, 913 heißt nicht überall chipTAN QR. Der Name wird mitgespeichert (`tan_verfahren_name`), damit das Formular ihn ohne Bankverbindung zeigen kann. diff --git a/docs/banking.md b/docs/banking.md index 6857ca9..c4fb412 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -58,22 +58,39 @@ ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und e fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe noch aus, bleibt die Maske stehen. -### Das Verfahren wählen +### Einrichtung in zwei Schritten -Fast jede Bank bietet mehrere Verfahren an. Welches gilt, steht am Zugang unter -*TAN-Verfahren einstellen* (`/banking/{id}/verfahren`). +Fast jede Bank bietet mehrere Verfahren an, und welche das sind, weiß nur sie. Fragen lässt +sie sich aber erst, wenn Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN vorliegen – +deshalb zerfällt das Anlegen einer Bankverbindung in zwei Schritte: + +1. **Zugangsdaten** (`/banking/neu`) – *Weiter* speichert sie. +2. **TAN-Verfahren** (`/banking/{id}/verfahren`) – die Seite fragt die Bank beim Aufruf + selbst und zeigt die Liste zur Wahl. + +Der zweite Schritt holt die Liste **ohne Knopfdruck**: Wer hier landet, will genau das, und +ein Knopf, dessen Ergebnis feststeht, wäre ein Schritt zu viel. Ein erneuter Anlauf steht +trotzdem bereit, wenn sich etwas geändert hat. + +Später erreicht man dieselbe Seite über *TAN-Verfahren einstellen* am Zugang. **Abgefragt, nicht eingetippt.** Die Kennungen sind bankspezifisch – 913 heißt nicht -überall chipTAN QR –, und niemand soll sie in einer PDF der Bank suchen müssen. Der Knopf -*Verfahren bei der Bank abfragen* holt die Liste über `getTanModes()`. Das kommt ohne -Anmeldung aus (die Bibliothek braucht dafür nur die Bankparameterdaten) und verlangt -deshalb selbst keine TAN. +überall chipTAN QR –, und niemand soll sie in einer PDF der Bank suchen müssen. +`getTanModes()` kommt ohne Anmeldung aus (die Bibliothek braucht dafür nur die +Bankparameterdaten) und verlangt deshalb selbst keine TAN. `getTanMedia()` wird nur für die Verfahren aufgerufen, die ein Medium brauchen: Jeder Aufruf ist ein eigener Dialog mit der Bank. -Die abgefragte Liste liegt in der Sitzung, nicht in der Datenbank – sie ist eine -Momentaufnahme. Beim Übernehmen wird gegen sie geprüft: Eine Kennung, die die Bank nicht +Die Zeitüberschreitungen sind kürzer gesetzt als in der Bibliothek (8 statt 15 Sekunden zum +Verbinden, 20 statt 30 zur Antwort): Die Abfrage hängt an einem Seitenaufruf. + +Das Ergebnis liegt in der Sitzung, **auch ein gescheitertes** – sonst liefe jeder +Seitenaufruf erneut in dieselbe Zeitüberschreitung. Scheitert die Abfrage, erklärt die +Seite, wo der Fehler meist steckt, und der Weg zurück zu den Zugangsdaten steht daneben. +Die Wahl lässt sich überspringen; die Verbindung ist auch ohne sie benutzbar. + +Beim Übernehmen wird gegen die abgefragte Liste geprüft: Eine Kennung, die die Bank nicht angeboten hat, wäre beim nächsten Abruf wirkungslos. **Ohne Wahl** nimmt der erste Abruf das erste Verfahren, das die Bank meldet, und hält es @@ -81,8 +98,7 @@ fest; sonst stünde im Formular nichts. Eine **ausdrückliche** Wahl wird dagege überschrieben: Bietet die Bank sie einmal nicht an, sagt das Protokoll das und die Einstellung bleibt stehen. -Das Verfahren steht bewusst nicht im Zugangsformular – dort ließe es sich nur als Zahl -eintragen. Das Formular fasst es deshalb auch nicht an: Ohne diesen Vorbehalt setzte jedes +Das Zugangsformular fasst Verfahren und Medium nicht an: Ohne diesen Vorbehalt setzte jedes Speichern die Wahl zurück. Abruf und Überweisung benutzen dieselbe Wahl (`Services\FinTsVerfahren::anwenden()`) – eine diff --git a/public/static/app.css b/public/static/app.css index 302cf60..39ee051 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -307,7 +307,10 @@ form.inline { display: inline; margin: 0; } font-weight: 600; } -.phasenmarke { +/* Kleine Marke: Zustand (Monatsabschluss), Dateiname (Import) oder der Schritt + eines mehrteiligen Dialogs (Einrichtung einer Bankverbindung). */ +.phasenmarke, +.schrittmarke { display: inline-block; background: var(--akzent-schwach); color: var(--akzent); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 00a6695..529f125 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -70,9 +70,11 @@ class BankingController extends BaseController return; } + // Weiter zum zweiten Schritt: Erst jetzt – mit gespeicherten Zugangsdaten – + // lässt sich die Bank nach ihren TAN-Verfahren fragen. View::redirect( - '/banking/' . $id, - 'Bankverbindung angelegt. Beim ersten Abruf werden die Konten der Bank ermittelt.' + '/banking/' . $id . '/verfahren?einrichtung=1', + 'Zugangsdaten gespeichert. Jetzt das TAN-Verfahren wählen.' ); } @@ -127,9 +129,15 @@ class BankingController extends BaseController // ── TAN-Verfahren ──────────────────────────────────────────────────────── /** - * Die abgefragte Verfahrensliste. Sie liegt in der Sitzung, damit ein - * erneutes Anzeigen der Seite – etwa nach einer Fehlermeldung – nicht jedes - * Mal wieder die Bank behelligt. + * Der zweite Schritt der Einrichtung: das TAN-Verfahren. + * + * Die Liste wird beim Aufruf **selbst geholt** – erst mit gespeicherten + * Zugangsdaten lässt sich die Bank fragen, und genau dann steht der Nutzer + * hier. Ein eigener Knopf davor wäre ein Schritt, dessen Ergebnis ohnehin + * feststeht. + * + * Das Ergebnis bleibt in der Sitzung, auch ein gescheitertes: Sonst liefe + * jeder Seitenaufruf erneut in dieselbe Zeitüberschreitung. * * @param array $params */ @@ -144,33 +152,32 @@ class BankingController extends BaseController return; } + $abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id); + $this->render('banking/verfahren.php', [ - 'titel' => 'TAN-Verfahren – ' . $zugang['name'], - 'zugang' => $zugang, - 'medium' => Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')), - 'verfahren' => $this->gemerkteVerfahren($id), + 'titel' => 'TAN-Verfahren – ' . $zugang['name'], + 'zugang' => $zugang, + 'medium' => Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')), + 'verfahren' => $abfrage['liste'], + 'fehler' => $abfrage['status'] === 'fehler' ? $abfrage['meldung'] : '', + 'einrichtung' => $this->query('einrichtung') === '1', ]); } - /** @param array $params */ + /** + * Ein erneuter Anlauf, wenn der erste scheiterte oder sich etwas geändert hat. + * + * @param array $params + */ public function verfahrenAbfragen(array $params): void { $this->verlangeAnmeldung(); - $id = (int)$params['id']; - $ergebnis = FinTsVerfahren::abfragen($id); + $id = (int)$params['id']; + $abfrage = $this->verfahrenHolen($id); + $ziel = '/banking/' . $id . '/verfahren' . ($this->post('einrichtung') === '1' ? '?einrichtung=1' : ''); - Auth::startSession(); - $_SESSION[self::SITZUNG_VERFAHREN] = [ - 'zugang_id' => $id, - 'liste' => $ergebnis['verfahren'], - ]; - - View::redirect( - '/banking/' . $id . '/verfahren', - $ergebnis['meldung'], - $ergebnis['status'] === 'fehler' ? 'fehler' : 'erfolg' - ); + View::redirect($ziel, $abfrage['meldung'], $abfrage['status'] === 'fehler' ? 'fehler' : 'erfolg'); } /** @param array $params */ @@ -180,6 +187,7 @@ class BankingController extends BaseController $id = (int)$params['id']; $verfahren = (int)$this->post('verfahren_id'); + $abfrage = $this->gemerkteAbfrage($id); try { FinTsVerfahren::uebernehmen( @@ -188,31 +196,61 @@ class BankingController extends BaseController // Das Medium des angekreuzten Verfahrens, nicht das irgendeiner // anderen Zeile. $this->post('tan_medium_' . $verfahren), - $this->gemerkteVerfahren($id) + $abfrage === null ? [] : $abfrage['liste'] ); } catch (\Throwable $e) { View::redirect('/banking/' . $id . '/verfahren', $e->getMessage(), 'fehler'); return; } - View::redirect('/banking/' . $id, 'TAN-Verfahren übernommen.'); + View::redirect( + '/banking/' . $id, + $this->post('einrichtung') === '1' + ? 'Bankverbindung eingerichtet. Der erste Abruf ermittelt jetzt die Konten.' + : 'TAN-Verfahren übernommen.' + ); + } + + /** + * Fragt die Bank und merkt sich das Ergebnis für diese Sitzung. + * + * @return array{status: string, meldung: string, liste: list>} + */ + private function verfahrenHolen(int $zugangId): array + { + $ergebnis = FinTsVerfahren::abfragen($zugangId); + + $abfrage = [ + 'status' => $ergebnis['status'], + 'meldung' => $ergebnis['meldung'], + 'liste' => $ergebnis['verfahren'], + ]; + + Auth::startSession(); + $_SESSION[self::SITZUNG_VERFAHREN] = ['zugang_id' => $zugangId] + $abfrage; + + return $abfrage; } /** * Die zuletzt abgefragte Liste – aber nur die zu diesem Zugang. * - * @return list> + * @return array{status: string, meldung: string, liste: list>}|null */ - private function gemerkteVerfahren(int $zugangId): array + private function gemerkteAbfrage(int $zugangId): ?array { Auth::startSession(); $gemerkt = $_SESSION[self::SITZUNG_VERFAHREN] ?? null; if (!is_array($gemerkt) || (int)($gemerkt['zugang_id'] ?? 0) !== $zugangId) { - return []; + return null; } - return is_array($gemerkt['liste'] ?? null) ? $gemerkt['liste'] : []; + return [ + 'status' => (string)($gemerkt['status'] ?? 'fehler'), + 'meldung' => (string)($gemerkt['meldung'] ?? ''), + 'liste' => is_array($gemerkt['liste'] ?? null) ? $gemerkt['liste'] : [], + ]; } // ── Abruf ──────────────────────────────────────────────────────────────── diff --git a/src/Services/FinTsVerfahren.php b/src/Services/FinTsVerfahren.php index 5e6c9c2..9d49dbf 100644 --- a/src/Services/FinTsVerfahren.php +++ b/src/Services/FinTsVerfahren.php @@ -278,6 +278,12 @@ class FinTsVerfahren $optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT; $optionen->productVersion = self::PRODUKT_VERSION; + // Kürzer als die Vorgabe der Bibliothek (15/30): Die Abfrage hängt an + // einem Seitenaufruf, und eine Bank, die nicht antwortet, soll die Seite + // nicht minutenlang aufhalten. + $optionen->timeoutConnect = 8; + $optionen->timeoutResponse = 20; + $fints = FinTs::new( $optionen, Credentials::create((string)$zugang['benutzerkennung'], (string)$zugang['pin']) diff --git a/src/Views/banking/formular.php b/src/Views/banking/formular.php index 4afb985..98fc411 100644 --- a/src/Views/banking/formular.php +++ b/src/Views/banking/formular.php @@ -16,6 +16,10 @@ $wert = static fn(string $feld, string $standard = ''): string => (string)($zu include __DIR__ . '/../layout/kopf.php'; ?> + +

Schritt 1 von 2 · Zugangsdaten

+ +
Zurück zu den Bankverbindungen @@ -95,9 +99,7 @@ include __DIR__ . '/../layout/kopf.php'; Die Wahl steht auf einer eigenen Seite, weil dafür die Bank gefragt werden muss: TAN-Verfahren einstellen.

- -

Konten bei dieser Bank

@@ -153,8 +155,15 @@ include __DIR__ . '/../layout/kopf.php'; + +

+ Nach dem Speichern fragt der Finanzmanager die Bank, welche TAN-Verfahren sie für + diese Benutzerkennung anbietet – ohne gespeicherte Zugangsdaten geht das nicht. +

+ +
- +
diff --git a/src/Views/banking/verfahren.php b/src/Views/banking/verfahren.php index b30ce04..396b1bc 100644 --- a/src/Views/banking/verfahren.php +++ b/src/Views/banking/verfahren.php @@ -3,74 +3,81 @@ use Finanzmanager\View; /** - * Die Wahl des TAN-Verfahrens für einen Bankzugang. + * Schritt 2 der Einrichtung: das TAN-Verfahren. + * + * Die Liste hat der Controller schon geholt – hier steht nur noch die Wahl. * * @var array $zugang * @var string $medium Das gespeicherte TAN-Medium im Klartext * @var list, hinweis:string}> $verfahren + * @var string $fehler Meldung, wenn die Abfrage scheiterte + * @var bool $einrichtung Teil des Einrichtungsablaufs? * @var string $csrfToken */ $gewaehlt = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id']; +$zugangId = (int)$zugang['id']; include __DIR__ . '/../layout/kopf.php'; ?> + +

Schritt 2 von 2 · TAN-Verfahren

+ +
- Zurück zur Bankverbindung -
+ + + + - + +
-

- Welche Verfahren zur Verfügung stehen, weiß nur die Bank – die Kennungen sind - von Haus zu Haus verschieden. Die Abfrage kommt ohne Anmeldung aus und verlangt - deshalb selbst keine TAN. -

- -

Zurzeit eingestellt

- -

- Noch nichts gewählt. Beim nächsten Abruf nimmt der Finanzmanager das erste - Verfahren, das die Bank meldet, und merkt es sich – das muss nicht das sein, - mit dem du sonst arbeitest. + +

+

- -

- - - - Kennung +

+ Meist stimmt eine Angabe im ersten Schritt nicht – Bankleitzahl, FinTS-Adresse, + Benutzerkennung oder PIN. + Zugangsdaten prüfen. + Die Wahl lässt sich auch später nachholen; ohne sie nimmt der erste Abruf das erste + Verfahren, das die Bank meldet.

-

- Noch keine Liste abgefragt. Der Knopf oben holt sie – dafür müssen - Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN gespeichert sein. -

+ +

Die Bank hat kein Verfahren genannt.

+ -

Von der Bank angeboten

-
+

+ Welche Verfahren zur Verfügung stehen, weiß nur die Bank – die Kennungen sind von + Haus zu Haus verschieden. Zu wählen ist das, mit dem du sonst arbeitest. +

+ + + - + $eintrag): ?>
WahlVerfahrenKennungTAN-Medium
+ > + >
- +
+ +

Zurzeit eingestellt

+ +

+ Noch nichts gewählt. Beim nächsten Abruf nimmt der Finanzmanager das erste + Verfahren, das die Bank meldet, und merkt es sich. +

+ +

+ + + + Kennung +

+ + + diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index 5e6b596..aa48dc5 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -46,11 +46,11 @@ test.describe('Module der Phasen 4 bis 7', () => { ['/verwaltung', 'Verwaltung'], ] + // Jeder Bereich hat sein Modul: Die Überschrift gehört zum Modul, nicht + // zur früheren Platzhalterseite. for (const [pfad, titel] of punkte) { await page.goto(pfad) await expect(page.locator('h1')).toContainText(titel) - // Kein Platzhalter mehr: Alle Bereiche haben ihr Modul. - await expect(page.locator('.phasenmarke')).toHaveCount(0) } }) @@ -94,24 +94,28 @@ test.describe('Module der Phasen 4 bis 7', () => { await page.fill('#fints_url', 'https://banking.example.org/fints') await page.fill('#benutzerkennung', 'e2e-kunde') await page.fill('#pin', 'geheim') - await page.click('button:has-text("Anlegen")') - await expect(page.locator('.meldung')).toContainText('angelegt') + // Schritt 1 speichert nur die Zugangsdaten. + await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 2') + await page.click('button:has-text("Weiter")') + + // Schritt 2 fragt die Bank selbst nach ihren TAN-Verfahren. Die Adresse + // im Test führt nirgendwohin – geprüft wird deshalb der Fehlerweg: Die + // Seite erklärt, was zu tun ist, statt stehen zu bleiben. + await expect(page.locator('.schrittmarke')).toContainText('Schritt 2 von 2') + await expect(page.locator('h1')).toContainText('E2E-Testbank') + await expect(page.locator('.meldung-fehler')).toBeVisible() + await expect(page.locator('a:has-text("Zugangsdaten prüfen")')).toHaveCount(1) + + await page.click('a:has-text("Später einstellen")') await expect(page.locator('h1')).toContainText('E2E-Testbank') // Die PIN wird nie wieder angezeigt. await expect(page.locator('#pin')).toHaveValue('') await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/) - // Das TAN-Verfahren steht auf einer eigenen Seite. Ohne Abfrage bei der - // Bank ist dort nichts gewählt – abgefragt wird hier nicht, das bräuchte - // eine echte Bankverbindung. - await page.click('a:has-text("TAN-Verfahren einstellen")') - await expect(page.locator('h1')).toContainText('TAN-Verfahren') - await expect(page.locator('.platzhalter').first()).toContainText('Noch nichts gewählt') - await expect(page.locator('button:has-text("Verfahren bei der Bank abfragen")')).toHaveCount(1) - - await page.click('a:has-text("Zurück zur Bankverbindung")') + // Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar. + await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1) page.once('dialog', dialog => dialog.accept()) await page.click('button:has-text("Bankverbindung löschen")')