validate(); $fints = new static($options, $credentials); if ($persistedInstance !== null) { $fints->loadPersistedInstance($persistedInstance); } return $fints; } /** * This function allows fetching the BPD without knowing the user's credentials yet, by using an anonymous dialog. * Note: If this fails with an error saying that your bank does not support the anonymous dialog, you probably need * to use {@link NoPsd2TanMode} for regular login. * @param FinTsOptions $options Configuration options for the connection to the bank. * @param ?LoggerInterface $logger An optional logger to record messages exchanged with the bank. * @return BPD Bank parameters that tell the client software what features the bank supports. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. * @throws ServerException When the server resopnds with an error. */ public static function fetchBpd(FinTsOptions $options, ?LoggerInterface $logger = null): BPD { $options->validate(); $fints = new static($options, null); if ($logger !== null) { $fints->setLogger($logger); } return $fints->getBpd(); } /** Please use the factory above. */ protected function __construct(FinTsOptions $options, ?Credentials $credentials) { $this->options = $options; $this->credentials = $credentials; $this->setLogger(new NullLogger()); } /** * Destructing the object only disconnects. Please use {@link close()} if you want to properly "log out", i.e., end * the FinTs dialog. */ public function __destruct() { $this->disconnect(); } /** * Returns a serialized form this object. This is different from PHP's {@link \Serializable} in that it only * serializes parts and cannot simply be restored with {@link unserialize()}, because the {@link FinTsOptions} and * the {@link Credentials} need to be passed to {@link FinTs::new()}, in addition to the string returned here. * * Alternatively, you can use {@link loadPersistedInstance()} to separate constructing the instance and resuming it. * * There are broadly two reasons to persist the instance: * 1. During login or certain other actions, you may encounter a TAN/2FA request ({@link BaseAction::needsTan()} * returns true). In that case, you MUST call {@link submitTan()} or {@link checkDecoupledSubmission()} later, * without losing the dialog state in between. Depending on your application's circumstances, one option might be * to simply keep the {@link FinTs} instance itself alive in memory (e.g., in a CLI application, you can block * until the user provides the TAN). In most server-based scenarios, however, the PHP process will shut down and * a new PHP process will be started later, when the client calls again to provide the TAN. In this case, you * need to persist the {@link FinTs} instance and restore it later in order for the action to succeed. * 2. Even when there is no outstanding action and after logging out with {@link close()}, it's beneficial to * persist the instance (with $minimal=false). By reusing the cached BPD, UPD and TAN mode information upon the * next {@link login()}, a few roundtrips to the FinTS server can be avoided. * * IMPORTANT: Each serialized instance (each value returned from {@link persist()}) can only be used once. After * passing it to {@link FinTs::new()} or {@link loadPersistedInstance()}, you must consider it invalidated. To keep * the same instance/session alive, you must call {@link persist()} again. * * @param bool $minimal If true, the return value only contains the values necessary to complete an outstanding TAN * request, but not the relatively large BPD/UPD, which can always be retrieved again later with a few extra * requests to the server. So the persisting doesn't work for use case (2.) from above, but in turn, it saves * storage space. * @return string A serialized form of those parts of the FinTs instance that can reasonably be persisted (BPD, UPD, * Kundensystem-ID, etc.). Note that this usually contains some user data (user's name, account names and * sometimes a dialog ID that is equivalent to session cookie). So the returned string needs to be treated * carefully (not written to log files, only to a database or other storage system that would normally be used * for user data). The returned string never contains highly sensitive information (not the user's password or * PIN), so it probably does not need to be encrypted. Treat it like a session cookie of the same bank. * Note that this is not necessarily valid UTF-8, so you should store it as a BLOB column or raw bytes. */ public function persist(bool $minimal = false): string { // IMPORTANT: Be sure not to include highly sensitive user information here. return serialize([ // This should match loadPersistedInstanceVersion1(). 2, // Version of the serialized format. $minimal ? null : $this->bpd, $minimal ? null : $this->allowedTanModes, $minimal ? null : $this->upd, $this->selectedTanMode, $this->selectedTanMedium, $this->kundensystemId, $this->dialogId, $this->messageNumber, ]); } public function __serialize(): array { throw new \LogicException('FinTs cannot be serialize()-ed, you should call persist() instead.'); } public function __unserialize(array $data): void { throw new \LogicException( 'FinTs cannot be unserialize()-ed, you should pass $persistedInstance to FinTs::new() instead.'); } /** * Loads data from a previous {@link FinTs} instance, to reuse cached BPD/UPD information and/or to continue using * an ongoing session. The latter is necessary to complete a TAN request when the user provides the TAN in a fresh * PHP process. * * Unless it's not available to you at that time already, you can just pass the persisted instance into * {@link FinTs::new()} instead of calling this function. * * @param string $persistedInstance The return value of {@link persist()} of a previous FinTs instance, usually * from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be considered * invalid afterwards. To continue the session, call {@link persist()} again. * * @throws \InvalidArgumentException */ public function loadPersistedInstance(string $persistedInstance): void { $unserialized = unserialize($persistedInstance); if (!is_array($unserialized) || count($unserialized) === 0) { throw new \InvalidArgumentException("Invalid persistedInstance: '$persistedInstance'"); } $version = $unserialized[0]; $data = array_slice($unserialized, 1); if ($version === 2) { $this->loadPersistedInstanceVersion2($data); } else { throw new \InvalidArgumentException("Unknown persistedInstace version: '{$unserialized[0]}''"); } } private function loadPersistedInstanceVersion2(array $data): void { list( // This should match persist(). $this->bpd, $this->allowedTanModes, $this->upd, $this->selectedTanMode, $this->selectedTanMedium, $this->kundensystemId, $this->dialogId, $this->messageNumber, ) = $data; } /** @noinspection PhpUnused */ public function getLogger(): SanitizingLogger { return $this->logger; } /** * @param LoggerInterface $logger The logger to use going forward. Note that it will be wrapped in a * {@link SanitizingLogger} to protect sensitive information like usernames and PINs. */ public function setLogger(LoggerInterface $logger): void { if ($logger instanceof SanitizingLogger) { $this->logger = $logger; } else { $this->logger = new SanitizingLogger($logger, [$this->options, $this->credentials]); } } /** * @param int $connectTimeout The number of seconds to wait before aborting a connection attempt to the bank server. * @param int $responseTimeout The number of seconds to wait before aborting a request to the bank server. * @noinspection PhpUnused */ public function setTimeouts(int $connectTimeout, int $responseTimeout): void { $this->options->timeoutConnect = $connectTimeout; $this->options->timeoutResponse = $responseTimeout; } /** * Executes a strongly authenticated login action and returns it. With some banks, this requires a TAN. * @return DialogInitialization A {@link BaseAction} for the outcome of the login. You should check whether a TAN is * needed using {@link BaseAction::needsTan()} and, if so, let the user complete the TAN request from * {@link BaseAction::getTanRequest()} and then finish the login by passing the {@link BaseAction} * returned here to {@link submitTan()} or {@link checkDecoupledSubmission()}. See {@link execute()} for * details. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. */ public function login(): DialogInitialization { $this->requireTanMode(); $this->ensureSynchronized(); $this->messageNumber = 1; $login = new DialogInitialization($this->options, $this->requireCredentials(), $this->getSelectedTanMode(), $this->selectedTanMedium, $this->kundensystemId); $this->execute($login); return $login; } /** * Executes an action. Be sure to {@link login()} first. See the `\Fhp\Action` package for actions that can be * executed with this function. Note that, after this function returns, the action can be in the following states: * 1. If {@link BaseAction::needsTan()} returns true, the action isn't completed yet because needs a TAN or other * kind of two-factor authentication (2FA). In this case, use {@link BaseAction::getTanRequest()} to get more * information about the TAN/2FA that is needed. Your application then needs to interact with the user to obtain * the TAN (which should be passed into {@link submitTan()}) or to have them complete the 2FA check (which can * be verified with {@link checkDecoupledSubmission()}). Both of those functions require passing the same * {@link BaseAction} argument as an argument, and once they succeed, the action will be in the same completed * state as if it had been completed right away. * 2. If {@link BaseAction::needsPollingWait()} returns true, the action isn't completed yet because the server is * still running some slow operation. Importantly, the server has not necessarily accepted the action yet, so it * is absolutely required that the client keeps polling if they don't want the action to be abandoned. * In this case, use {@link BaseAction::getPollingInfo()} to get more information on how frequently to poll, and * do the polling through {@link pollAction()}. * 3. If {@link BaseAction::needsVopConfirmation()} returns true, the action isn't completed yet because the payee * information couldn't be matched automatically, so an explicit confirmation from the user is required. * In this case, use {@link BaseAction::getVopConfirmationRequest()} to get more information to display to the * user, ask the user to confirm that they want to proceed with the action, and then call {@link confirmVop()}. * 4. If none of the above return true, the action was completed right away. * Use the respective getters on the action instance to retrieve the result. In case the action fails, the * corresponding exception will be thrown from this function. * * Tip: In practice, polling (2.) and confirmation (3.) are needed only for Verification of Payee. So if your * application only ever executes read-only actions like account statement fetching, but never executes any * transfers, instead of handling these cases you could simply assert that {@link BaseAction::needsPollingWait()} * and {@link BaseAction::needsVopConfirmation()} both return false. * * Note that all conditions above that leave the action in an incomplete state require some action from the client * application. These actions then change the state of the action again, but they don't necessarily complete it. * In practice, the typical sequence is: Maybe polling, maybe VOP confirmation, maybe TAN, done. That said, you * should ideally implement your application to deal with any sequence of states. Just execute the action, check * what's state it's in, resolve that state as appropriate, and then check again (using the same code as before). Do * this repeatedly until none of the special conditions above happen anymore, at which point the action is done. * * @param BaseAction $action The action to be executed. Its {@link BaseAction::isDone()} status will be updated when * this function returns successfully. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. */ public function execute(BaseAction $action): void { if ($this->dialogId === null && !($action instanceof DialogInitialization)) { throw new \RuntimeException('Need to login (DialogInitialization) before executing other actions'); } // Add the action's main request segments. $requestSegments = $action->getNextRequest($this->bpd, $this->upd); if (count($requestSegments) === 0) { return; // No request needed. } $message = MessageBuilder::create()->add($requestSegments); // Add HKTAN for authentication if necessary. if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) { if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) { $message->add(HKTANFactory::createProzessvariante2Step1( $this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment)); } } // Add HKVPP for VOP verification if necessary. $hkvpp = null; if ($this->bpd?->vopRequiredForRequest($requestSegments) !== null) { $hkvpp = VopHelper::createHKVPPForInitialRequest($this->bpd); $message->add($hkvpp); } // Construct the request message and tell the action about the segment numbers that were assigned. $request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers. $action->setRequestSegmentNumbers(array_map(function ($segment) { /* @var BaseSegment $segment */ return $segment->getSegmentNumber(); }, $requestSegments)); // Execute the request. $response = $this->sendMessage($request); $this->processServerResponse($action, $response, $hkvpp); } /** * Updates the state of this FinTs instance and of the `$action` based on the server's response. * See {@link execute()} for more documentation on the possible outcomes. * @param BaseAction $action The action for which the request was sent. * @param Message $response The response we just got from the server. * @param HKVPPv1|null $hkvpp The HKVPP segment, if any was present in the request. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. */ private function processServerResponse(BaseAction $action, Message $response, ?HKVPPv1 $hkvpp = null): void { $this->readBPD($response); // Detect if the bank wants a TAN. /** @var HITAN $hitan */ $hitan = $response->findSegment(HITAN::class); // Note: Instead of DUMMY_REFERENCE, it's officially the 3076 Rueckmeldungscode that tells we don't need a TAN. if ($hitan !== null && $hitan->getAuftragsreferenz() !== HITAN::DUMMY_REFERENCE) { if ($hitan->tanProzess !== HKTAN::TAN_PROZESS_4) { throw new UnexpectedResponseException("Unsupported TAN request type $hitan->tanProzess"); } if ($this->bpd === null || $this->kundensystemId === null) { throw new UnexpectedResponseException('Unexpected TAN request'); } // NOTE: In case of a decoupled TAN mode, the response code 3955 must be present, but it seems useless to us. $action->setTanRequest($hitan); if ($action instanceof DialogInitialization) { $action->setDialogId($response->header->dialogId); $action->setMessageNumber($this->messageNumber); } } // Detect if the bank needs us to do something for Verification of Payee. if ($hkvpp != null) { if ($pollingInfo = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber())) { $action->setPollingInfo($pollingInfo); if ($action->needsTan()) { throw new UnexpectedResponseException('Unexpected polling and TAN request in the same response.'); } return; } if ($confirmationRequest = VopHelper::checkVopConfirmationRequired($response, $hkvpp->getSegmentNumber())) { $action->setVopConfirmationRequest($confirmationRequest); if ($action->needsTan()) { if ($confirmationRequest->getVerificationResult() === VopVerificationResult::CompletedFullMatch) { // If someone hits this branch in practice, we can implement it. throw new UnsupportedException('Combined VOP match confirmation and TAN request'); } else { throw new UnexpectedResponseException( 'Unexpected TAN request on VOP result: ' . $confirmationRequest->getVerificationResult() ); } } } } if ($action->needsVopConfirmation() || $action->needsTan()) { return; // The action isn't complete yet. } // If no TAN or VOP is needed, process the response normally, and maybe keep going for more pages. $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); if ($action instanceof PaginateableAction && $action->hasMorePages()) { $this->execute($action); } // Check whether the server requested a Kundensystem-ID refresh. if ($response->findRueckmeldung(Rueckmeldungscode::NEUE_KUNDENSYSTEM_ID_HOLEN) !== null) { // TODO Properly implement the refresh here, see https://github.com/nemiah/phpFinTS/issues/458. $this->logger->warning( 'The server asked us to refresh the Kundensystem-ID in response to a ' . gettype($action) . ' action, but that is not implemented yet. This could result in authentication errors or extraneous ' . ' re-authentication prompts from the bank.' ); } } /** * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns * `false`, this function sends the given $tan to the server to complete the action. By using {@link persist()}, * this can be done asynchronously, i.e., not in the same PHP process as the original {@link execute()} call. * * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. * In practice, the action is fully completed after completing the decoupled submission. * In case the action fails, the corresponding exception will be thrown from this function. * * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf * Section B.4.2.1.1 * * @param BaseAction $action The action to be completed. * @param string $tan The TAN entered by the user. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. */ public function submitTan(BaseAction $action, string $tan): void { // Check the action's state. $tanRequest = $action->getTanRequest(); if ($tanRequest === null) { throw new \InvalidArgumentException('This action does not need a TAN'); } if ($action instanceof DialogInitialization) { if ($this->dialogId !== null) { throw new \RuntimeException('Cannot init another dialog.'); } $this->dialogId = $action->getDialogId(); $this->messageNumber = $action->getMessageNumber(); } // Construct the request. $tanMode = $this->requireTanMode(); if ($tanMode instanceof NoPsd2TanMode) { throw new \InvalidArgumentException('Cannot submit TAN when the bank does not support PSD2'); } if ($tanMode->isDecoupled()) { throw new \InvalidArgumentException('Cannot submit TAN for a decoupled TAN mode'); } $message = MessageBuilder::create() ->add(HKTANFactory::createProzessvariante2Step2($tanMode, $tanRequest->getProcessId())); $request = $this->buildMessage($message, $tanMode, $tan); // Execute the request. $response = $this->sendMessage($request); $this->readBPD($response); // Ensure that the TAN was accepted. /** @var HITAN $hitan */ $hitan = $response->findSegment(HITAN::class); if ($hitan === null) { throw new UnexpectedResponseException('HITAN missing after submitting TAN'); } if ($hitan->getTanProzess() !== HKTAN::TAN_PROZESS_2 // We only support the case "(B)" in the specification. || $hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) { throw new UnexpectedResponseException("Bank has not accepted TAN: $hitan"); } $action->setTanRequest(null); // Process the response normally, and maybe keep going for more pages. $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); if ($action instanceof PaginateableAction && $action->hasMorePages()) { $this->execute($action); } } /** * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns * `true`, this function checks with the server whether the second factor authentication has been completed yet on * the secondary device of the user. * - If so, this function returns `true` and the `$action` is then in any of the same states as after * {@link execute()} (except {@link BaseAction::needsTan()} won't happen again). See there for documentation. * In practice, the action is fully completed after completing the decoupled submission. * - In case the action fails, the corresponding exception will be thrown from this function. * - If the authentication has not been completed yet, this returns `false` and the action remains in its * previous, uncompleted state. * * By using {@link persist()}, this function can be called asynchronously, i.e., not in the same PHP process as the * original {@link execute()} call. * * This function can be called repeatedly, subject to the delays specified in the {@link TanMode}. * IMPORTANT: Remember to re-{@link persist()} the {@link FinTs} instance after each * {@link checkDecoupledSubmission()} call. * * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf * Section B.4.2.2 * * @param BaseAction $action The action to be completed. * @return bool True if the decoupled authentication is done and the $action was completed or entered one of the * other states documented on {@link execute()}. * If false, the {@link TanRequest} inside the action has been updated, which *may* provide new/more * instructions to the user, though probably it rarely does in practice. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. */ public function checkDecoupledSubmission(BaseAction $action): bool { // Check the action's state. $tanRequest = $action->getTanRequest(); if ($tanRequest === null) { throw new \InvalidArgumentException('This action is not awaiting decoupled confirmation'); } if ($action instanceof DialogInitialization) { if ($this->dialogId !== null) { throw new \RuntimeException('Cannot init another dialog.'); } $this->dialogId = $action->getDialogId(); $this->messageNumber = $action->getMessageNumber(); } // (2a) Construct the request. $tanMode = $this->requireTanMode(); if ($tanMode instanceof NoPsd2TanMode) { throw new \InvalidArgumentException('Cannot check decoupled status when the bank does not support PSD2'); } if (!$tanMode->isDecoupled()) { throw new \InvalidArgumentException('Cannot check decoupled status for a non-decoupled TAN mode'); } $message = MessageBuilder::create() ->add(HKTANFactory::createProzessvariante2StepS($tanMode, $tanRequest->getProcessId())); $request = $this->buildMessage($message, $tanMode); // Execute the request. $response = $this->sendMessage($request); $this->readBPD($response); // Determine if the decoupled authentication has completed. See section B.4.2.2.1. // There is always at least one HITAN segment with TAN-Prozess=S and the reference ID. // (2b) The response code 3956 indicates that the authentication is still outstanding. There could also be more // information for the user in the HITAN challenge field. // (2c) Note that we only support the (B) variant here. There is additionally supposed to be a HITAN segment // with TAN-Prozess=2 and the reference ID to indicate that the authentication has completed, though not // all banks actually send this, as they seem to consider the absence of 3956 as sufficient for signaling // success. In this case, the response also contains the response segments for the executed action, if any. $hitanProcessS = null; /** @var HITAN $hitan */ foreach ($response->findSegments(HITAN::class) as $hitan) { if ($hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) { throw new UnexpectedResponseException('Unexpected Auftragsreferenz: ' . $hitan->getAuftragsreferenz()); } if ($hitan->getTanProzess() === HKTAN::TAN_PROZESS_S) { $hitanProcessS = $hitan; } } if ($hitanProcessS === null) { throw new UnexpectedResponseException('Missing HITAN with tanProzess=S in the response'); } if ($response->findRueckmeldungen(Rueckmeldungscode::STARKE_KUNDENAUTHENTIFIZIERUNG_NOCH_AUSSTEHEND)) { // The decoupled submission isn't complete yet. Update the TAN request, as the bank may have sent additional // instructions. $action->setTanRequest($hitanProcessS); if ($action instanceof DialogInitialization) { $this->dialogId = null; $action->setMessageNumber($this->messageNumber); } return false; } // The decoupled submission is complete and the action's result is included in the response. $action->setTanRequest(null); // Process the response normally, and maybe keep going for more pages. $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); if ($action instanceof PaginateableAction && $action->hasMorePages()) { $this->execute($action); } return true; } /** * For an action where {@link BaseAction::needsPollingWait()} returns `true`, this function polls the server. * By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original * {@link execute()} call or the previous {@link pollAction()} call. * * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In * particular, it's possible that the long-running operation on the server has not completed yet and thus * {@link BaseAction::needsPollingWait()} still returns `true`. In practice, actions often require VOP confirmation * or a TAN after the polling is over, though they can also complete right away. * In case the action fails, the corresponding exception will be thrown from this function. * * @param BaseAction $action The action to be completed. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. * @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf * Section C.10.7.1.1 a) */ public function pollAction(BaseAction $action): void { $pollingInfo = $action->getPollingInfo(); if ($pollingInfo === null) { throw new \InvalidArgumentException('This action is not awaiting polling for a long-running operation'); } elseif ($pollingInfo instanceof VopPollingInfo) { // Only send a new HKVPP. $hkvpp = VopHelper::createHKVPPForPollingRequest($this->bpd, $pollingInfo); $message = MessageBuilder::create()->add($hkvpp); // Execute the request and process the response. $response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode())); $action->setPollingInfo(null); $this->processServerResponse($action, $response, $hkvpp); } else { throw new \InvalidArgumentException('Unexpected PollingInfo type: ' . gettype($pollingInfo)); } } /** * For an action where {@link BaseAction::needsVopConfirmation()} returns `true`, this function re-submits the * action with the additional confirmation from the user that they want to execute the transfer(s) after having * reviewed the information from the {@link VopConfirmationRequest}. * By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original * {@link execute()} call. * * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In * practice, actions often require a TAN after VOP is confirmed, though they can also complete right away. * In case the action fails, the corresponding exception will be thrown from this function. * * @param BaseAction $action The action to be completed. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. * @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf * Section C.10.7.1.2 a) */ public function confirmVop(BaseAction $action): void { $vopConfirmationRequest = $action->getVopConfirmationRequest(); if (!($vopConfirmationRequest instanceof VopConfirmationRequestImpl)) { throw new \InvalidArgumentException('Unexpected type: ' . gettype($vopConfirmationRequest)); } // We need to send the original request again, plus HKVPA as the confirmation. $requestSegments = $action->getNextRequest($this->bpd, $this->upd); if (count($requestSegments) === 0) { throw new \AssertionError('Request unexpectedly became empty upon VOP confirmation'); } $message = MessageBuilder::create() ->add($requestSegments) ->add(VopHelper::createHKVPAForConfirmation($vopConfirmationRequest)); // Add HKTAN for authentication if necessary. if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) { if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) { $message->add(HKTANFactory::createProzessvariante2Step1( $this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment)); } } // Construct the request message and tell the action about the segment numbers that were assigned. $request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers. $action->setRequestSegmentNumbers(array_map(function ($segment) { /* @var BaseSegment $segment */ return $segment->getSegmentNumber(); }, $requestSegments)); // Execute the request and process the response. $response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode())); $action->setVopConfirmationRequest(null); $this->processServerResponse($action, $response); } /** * Closes the session/dialog/connection, if open. This is equivalent to logging out. You should call this function * when you're done with all the actions, but NOT when you're persisting the instance to fulfill the TAN request of * an outstanding action. * This FinTs object remains usable even after closing the session. You can still {@link persist()} it to benefit * from cached BPD/UPD upon the next {@link login()}, for instance. * @throws ServerException When closing the dialog fails. */ public function close(): void { if ($this->dialogId !== null) { $this->endDialog(); } $this->disconnect(); } /** * Assumes that the session/dialog (if any is open) is gone, but keeps any cached BPD/UPD for reuse (to allow for * faster re-login). * This can be called by the application using this library when it just restored this FinTs instance from the * persisted format after a long time, during which the session/dialog has most likely expired on the server side. */ public function forgetDialog(): void { $this->dialogId = null; } /** * Before executing any actions that might require two-step authentication (like fetching a statement or initiating * a wire transfer), the user needs to pick a {@link TanMode}. Note that this does not always imply that the user * actually needs to enter a TAN every time, but they need to have picked the mode so that the system knows how to * deliver a TAN, if necesssary. * @return TanMode[] The TAN modes that are available to the user, indexed by their IDs. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes * like it should according to the protocol, or when the dialog is not closed properly. * @throws ServerException When the server responds with an error. */ public function getTanModes(): array { $this->ensureTanModesAvailable(); $result = []; foreach ($this->allowedTanModes as $tanModeId) { if (!array_key_exists($tanModeId, $this->bpd->allTanModes)) { continue; } $result[$tanModeId] = $this->bpd->allTanModes[$tanModeId]; } return $result; } /** * For TAN modes where {@link TanMode::needsTanMedium()} returns true, the user additionally needs to pick a TAN * medium. This function returns a list of possible TAN media. Note that, depending on the bank, this list may * contain all the user's TAN media, or just the ones that are compatible with the given $tanMode. * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID. * @return TanMedium[] A list of possible TAN media. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN media * (which includes the case where the server does not support enumerating TAN media, which is indicated by * {@link TanMode::needsTanMedium()} returning false), or when the dialog is not closed properly. * @throws ServerException When the server responds with an error. */ public function getTanMedia($tanMode): array { if ($this->dialogId !== null) { $this->endDialog(); } $this->ensureBpdAvailable(); $this->ensureSynchronized(); $getTanMedia = new GetTanMedia(); // Execute the GetTanMedia request with the $tanMode swapped in temporarily. $oldTanMode = $this->selectedTanMode; $oldTanMedium = $this->selectedTanMedium; $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode; $this->selectedTanMedium = ''; try { $this->executeWeakDialogInitialization('HKTAB'); $this->execute($getTanMedia); $this->endDialog(); return $getTanMedia->getTanMedia(); } catch (UnexpectedResponseException|CurlException|ServerException $e) { throw $e; } finally { $this->selectedTanMode = $oldTanMode; $this->selectedTanMedium = $oldTanMedium; } } /** * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID. * @param TanMedium|string|null $tanMedium If the $tanMode has {@link TanMode::needsTanMedium()} set to true, this * must be the value returned from {@link TanMedium::getName()} for one of the TAN media supported with that TAN * mode. Use {@link getTanMedia()} to obtain a list of possible TAN media options. */ public function selectTanMode($tanMode, $tanMedium = null): void { if (!is_int($tanMode) && !($tanMode instanceof TanMode)) { throw new \InvalidArgumentException('tanMode must be an int or a TanMode'); } if ($tanMedium !== null && !is_string($tanMedium) && !($tanMedium instanceof TanMedium)) { throw new \InvalidArgumentException('tanMedium must be a string or a TanMedium'); } $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode; $this->selectedTanMedium = $tanMedium instanceof TanMedium ? $tanMedium->getName() : $tanMedium; } /** * Fetches the BPD from the server, if they are not already present at the client, and then returns them. Note that * this does not require user login. * @return BPD The BPD from the bank. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. * @throws ServerException When the server resopnds with an error. */ public function getBpd(): BPD { $this->ensureBpdAvailable(); return $this->bpd; } // ------------------------------------------------- IMPLEMENTATION ------------------------------------------------ /** * Ensures that the latest BPD data is present by executing an anonymous dialog (including initialization and * termination of the dialog) if necessary. Executing this does not require (strong or any) authentication, and it * makes the {@link $bpd} available. * * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf * Section: C.5.1 (and also C.3.1.1) * * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. * @throws ServerException When the server resopnds with an error. */ private function ensureBpdAvailable(): void { if ($this->bpd !== null) { return; // Nothing to do. } if ($this->dialogId !== null) { throw new \RuntimeException('Cannot init another dialog.'); } if ($this->selectedTanMode === NoPsd2TanMode::ID || $this->selectedTanMode instanceof NoPsd2TanMode) { // For banks that don't support PSD2, we also don't use an anonymous dialog to obtain the BPD. The more // common procedure before PSD2 was to just get the BPD upon first login. Thus execute(DialogInitialization) // tolerates not having a BPD yet. return; } // We must always include HKTAN in order to signal that strong authentication (PSD2) is supported (section // B.4.3.1). As this is the first contact with the server, we don't know which HKTAN versions it supports, so we // just sent HKTANv6 as it's currently most supported by banks. $initRequest = Message::createPlainMessage(MessageBuilder::create() ->add(HKIDNv2::createAnonymous($this->options->bankCode)) ->add(HKVVBv3::create($this->options, null, null)) // Pretend we have no BPD/UPD. ->add(HKTANv6::createDummy())); $initResponse = $this->sendMessage($initRequest); if (!$this->readBPD($initResponse)) { throw new UnexpectedResponseException('Did not receive BPD'); } $this->dialogId = $initResponse->header->dialogId; $this->endDialog(true); } private function requireCredentials(): Credentials { if ($this->credentials === null) { throw new \LogicException('This action is not allowed on a FinTs instance without Credentials'); } return $this->credentials; } /** * Ensures that the {@link $allowedTanModes} are available by executing a personalized, TAN-less dialog * initialization (and closing the dialog again), if necessary. Executing this only requires the {@link Credentials} * but no strong authentication. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes * like it should according to the protocol, or when the dialog is not closed properly. * @throws ServerException When the server responds with an error. */ private function ensureTanModesAvailable(): void { if ($this->allowedTanModes === null) { $this->ensureBpdAvailable(); $this->ensureSynchronized(); // The response here will contain 3920, which is written to $allowedTanModes. if ($this->allowedTanModes === null) { throw new UnexpectedResponseException('No TAN modes received'); } } } /** * Ensures that we have a {@link $kundensystemId} by executing a synchronization dialog (and closing it again) if * if necessary. Executing this does not require strong authentication. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID, or when the * dialog is not closed properly. * @throws ServerException When the server responds with an error. */ private function ensureSynchronized(): void { if ($this->kundensystemId === null) { $this->ensureBpdAvailable(); // Execute dialog initialization without a TAN mode/medium, so using the fake mode 999. While most banks // accept the real TAN mode for synchronization (as defined in the specification), some get confused by the // presence of anything other than 999 into thinking that strong authentication is required. And for those // banks that don't support PSD2, we just keep the dummy TAN mode, as they wouldn't even understand 999. $oldTanMode = $this->selectedTanMode; $oldTanMedium = $this->selectedTanMedium; if (!($this->selectedTanMode instanceof NoPsd2TanMode)) { $this->selectedTanMode = null; } $this->selectedTanMedium = null; try { $this->executeWeakDialogInitialization(null); if ($this->kundensystemId === null) { throw new UnexpectedResponseException('No Kundensystem-ID retrieved from sync.'); } $this->endDialog(); } finally { $this->selectedTanMode = $oldTanMode; $this->selectedTanMedium = $oldTanMedium; } } } /** * If the selected TAN mode was provided as an int, resolves it to a full {@link TanMode} instance, which may * involve a request to the server to retrieve the BPD. Then returns it. * @return TanMode|null The current TAN mode, null if none was selected, never an int. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws ServerException When the server resopnds with an error during the BPD fetch. */ public function getSelectedTanMode(): ?TanMode { if ($this->selectedTanMode === NoPsd2TanMode::ID) { $this->selectedTanMode = new NoPsd2TanMode(); } elseif (is_int($this->selectedTanMode)) { $this->ensureBpdAvailable(); if (!array_key_exists($this->selectedTanMode, $this->bpd->allTanModes)) { throw new \InvalidArgumentException("Unknown TAN mode: $this->selectedTanMode"); } $this->selectedTanMode = $this->bpd->allTanModes[$this->selectedTanMode]; if (!$this->selectedTanMode->isProzessvariante2()) { throw new UnsupportedException('Only supports Prozessvariante 2'); } if ($this->selectedTanMode->needsTanMedium()) { if ($this->selectedTanMedium === null) { throw new \InvalidArgumentException('tanMedium is mandatory for this tanMode'); } } else { if ($this->selectedTanMedium !== null) { throw new \InvalidArgumentException('tanMedium not allowed for this tanMode'); } } } return $this->selectedTanMode; } /** * Like {@link getSelectedTanMode()}, but throws an exception if none was selected. * @return TanMode The current TAN mode. * @throws \RuntimeException If no TAN mode has been selected. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws ServerException When the server resopnds with an error during the BPD fetch. */ private function requireTanMode(): TanMode { $tanMode = $this->getSelectedTanMode(); if ($tanMode === null) { throw new \RuntimeException('selectTanMode() must be called before login() or execute()'); } return $tanMode; } /** * Creates a new connection based on the {@link $options}. This can be overridden for unit testing purposes. * @return Connection A newly instantiated connection. */ protected function newConnection(): Connection { return new Connection($this->options->url, $this->options->timeoutConnect, $this->options->timeoutResponse); } /** * Closes the physical connection, if necessary. */ private function disconnect(): void { if ($this->connection !== null) { $this->connection->disconnect(); $this->connection = null; } } /** * Passes the response segments to the action for post-processing of the response. * @param BaseAction $action The action to which the response belongs. * @param Message $fakeResponseMessage A messsage that contains the response segments for this action. * @throws UnexpectedResponseException When the server responded with a valid but unexpected message. */ private function processActionResponse(BaseAction $action, Message $fakeResponseMessage): void { $action->processResponse($fakeResponseMessage); if ($action instanceof DialogInitialization) { $this->dialogId = $action->getDialogId(); if ($this->kundensystemId === null && $action->getKundensystemId()) { $this->kundensystemId = $action->getKundensystemId(); } if ($action->getUpd() !== null) { $this->upd = $action->getUpd(); } elseif ($this->upd === null && $action->isStronglyAuthenticated()) { throw new UnexpectedResponseException('No UPD received'); } } } /** * Initialize a personalized dialog with weak authentication (no two-step authentication, no TAN, using the fake * mode with ID 999 instead), which can be used for certain less sensitive business transactions, including HKTAB to * retrieve the TAN media list. This is for Authentifizierungsklasse 1 and 4 (conditionally). * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf * Section: B.3 * @param string|null $hktanRef The identifier of the main PIN/TAN management segment to be executed in this dialog, * or null for a general weakly authenticated dialog. See {@link DialogInitialization} for documentation. * @throws CurlException When the connection fails in a layer below the FinTS protocol. * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID as it should * according to the protocol, when it asks for a TAN even though it shouldn't, or when the dialog is not closed * properly. * @throws ServerException When the server responds with an error. */ private function executeWeakDialogInitialization(?string $hktanRef): void { if ($this->dialogId !== null) { throw new \RuntimeException('Cannot init another dialog.'); } $this->messageNumber = 1; $dialogInitialization = new DialogInitialization($this->options, $this->requireCredentials(), $this->getSelectedTanMode(), $this->selectedTanMedium, $this->kundensystemId, $hktanRef); $this->execute($dialogInitialization); if ($dialogInitialization->needsTan()) { throw new UnexpectedResponseException('Server asked for TAN on a dialog meant for weak authentication'); } } /** * @param Message $response A response retrieved from the server that may or may not contain the BPD. * @return bool Whether the BPD was found in the response. */ private function readBPD(Message $response): bool { if ($allowed = $response->findRueckmeldung(Rueckmeldungscode::ZUGELASSENE_VERFAHREN)) { $this->allowedTanModes = array_map('intval', $allowed->rueckmeldungsparameter); } if (!$response->hasSegment(HIBPAv3::class)) { return false; } $this->bpd = BPD::extractFromResponse($response); if (!$this->bpd->supportsPsd2() && !($this->selectedTanMode instanceof NoPsd2TanMode)) { throw new UnsupportedException('The bank does not support PSD2.'); } return true; } /** * Closes the currently active dialog, if any. Note that this does *not* close the connection, it is possible to * open a new dialog on the same connection. * @param bool $isAnonymous If set to true, the HKEND message will not be wrapped into an encryption envelope. * @throws ServerException When the server responds with an error instead of closing the dialog. This means that * the connection is tainted and can probably not be used for another dialog. */ protected function endDialog(bool $isAnonymous = false): void { if ($this->connection === null) { $this->dialogId = null; return; } try { if ($this->dialogId !== null) { $message = MessageBuilder::create()->add(HKENDv1::create($this->dialogId)); $request = $isAnonymous ? Message::createPlainMessage($message) : $this->buildMessage($message, $this->getSelectedTanMode()); $response = $this->sendMessage($request); if ($response->findRueckmeldung(Rueckmeldungscode::BEENDET) === null) { throw new UnexpectedResponseException( 'Server did not confirm dialog end, but did not send error either'); } } } catch (CurlException $e) { // Ignore, we want to disconnect anyway. } catch (ServerException $e) { if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) { // We wanted to end the dialog, but the server already canceled it before. $this->logger->warning("Dialog already ended: $e"); } else { // Something else went wrong. throw $e; } } finally { $this->dialogId = null; } } /** * Injects FinTsOptions/BPD/UPD/Credentials information into the message. * @param MessageBuilder $message The message to be built. * @param TanMode|null $tanMode Optionally a TAN mode that will be used when sending this message, defaults to 999 * (single step). * @param string|null $tan Optionally a TAN to sign this message with. * @return Message The built message. */ private function buildMessage(MessageBuilder $message, ?TanMode $tanMode = null, ?string $tan = null): Message { return Message::createWrappedMessage( $message, $this->options, $this->kundensystemId === null ? '0' : $this->kundensystemId, $this->requireCredentials(), $tanMode, $tan ); } /** * Finalizes a message (conversion to wire format, filling in message number and size), sends it to the bank and * parses the response, plus logging. * @param MessageBuilder|Message $request The message to be sent. * @return Message The response from the server. * @throws CurlException When the request failed on the physical or TCP/HTTPS protocol level. * @throws ServerException When the response contains an error. */ private function sendMessage($request): Message { if ($request instanceof MessageBuilder) { $request = $this->buildMessage($request, $this->getSelectedTanMode()); } $request->header->dialogId = $this->dialogId === null ? '0' : $this->dialogId; $request->header->nachrichtennummer = $this->messageNumber; $request->footer->nachrichtennummer = $this->messageNumber; ++$this->messageNumber; $request->header->setNachrichtengroesse(strlen($request->serialize())); $request->validate(); if ($this->connection === null) { $this->connection = $this->newConnection(); } $rawRequest = $request->serialize(); $this->logger->debug('> ' . $rawRequest); try { $rawResponse = $this->connection->send($rawRequest); $this->logger->debug('< ' . $rawResponse); } catch (CurlException $e) { $this->logger->critical($e->getMessage()); $this->logger->debug(print_r($e->getCurlInfo(), true)); $this->disconnect(); throw $e; } try { $response = Message::parse($rawResponse); } catch (\InvalidArgumentException $e) { $this->disconnect(); throw new InvalidResponseException('Invalid response from server', 0, $e); } try { ServerException::detectAndThrowErrors($response, $request); } catch (ServerException $e) { $this->disconnect(); if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) { $this->forgetDialog(); } throw $e; } return $response; } }