- php-fints Bibliothek von 3.7.0 auf 4.0.0 aktualisiert - Parser-Fix: Ignoriert zusätzliche Bank-Felder statt Exception - HKEKA Segmente implementiert (HIEKASv5, HKEKAv5, HIEKAv5) - HKKAA Segmente implementiert (HIKAASv1, HKKAAv1) - GetStatementFromArchive und GetElectronicStatement Actions HINWEIS: HKKAA/HKEKA funktionieren noch nicht mit VR Bank (Fehler "unerwarteter Aufbau wrt DE 2" - Kontoverbindungsformat) Normale Funktionalität (Transaktionsimport) ist nicht betroffen. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1206 lines
62 KiB
PHP
1206 lines
62 KiB
PHP
<?php
|
|
|
|
namespace Fhp;
|
|
|
|
use Fhp\Model\NoPsd2TanMode;
|
|
use Fhp\Model\TanMedium;
|
|
use Fhp\Model\TanMode;
|
|
use Fhp\Model\VopConfirmationRequest;
|
|
use Fhp\Model\VopConfirmationRequestImpl;
|
|
use Fhp\Model\VopPollingInfo;
|
|
use Fhp\Model\VopVerificationResult;
|
|
use Fhp\Options\Credentials;
|
|
use Fhp\Options\FinTsOptions;
|
|
use Fhp\Options\SanitizingLogger;
|
|
use Fhp\Protocol\BPD;
|
|
use Fhp\Protocol\DialogInitialization;
|
|
use Fhp\Protocol\GetTanMedia;
|
|
use Fhp\Protocol\Message;
|
|
use Fhp\Protocol\MessageBuilder;
|
|
use Fhp\Protocol\ServerException;
|
|
use Fhp\Protocol\UnexpectedResponseException;
|
|
use Fhp\Protocol\UPD;
|
|
use Fhp\Segment\BaseSegment;
|
|
use Fhp\Segment\HIBPA\HIBPAv3;
|
|
use Fhp\Segment\HIRMS\Rueckmeldungscode;
|
|
use Fhp\Segment\HKEND\HKENDv1;
|
|
use Fhp\Segment\HKIDN\HKIDNv2;
|
|
use Fhp\Segment\HKVVB\HKVVBv3;
|
|
use Fhp\Segment\TAN\HITAN;
|
|
use Fhp\Segment\TAN\HKTAN;
|
|
use Fhp\Segment\TAN\HKTANFactory;
|
|
use Fhp\Segment\TAN\HKTANv6;
|
|
use Fhp\Segment\VPP\HKVPPv1;
|
|
use Fhp\Segment\VPP\VopHelper;
|
|
use Fhp\Syntax\InvalidResponseException;
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\NullLogger;
|
|
|
|
/**
|
|
* This is the main class of this library. Please see the Samples directory for how to use it.
|
|
* This class is not thread-safe. Do not call its funtions concurrently.
|
|
*/
|
|
class FinTs
|
|
{
|
|
// Things we retrieved from the user / the calling application.
|
|
/** @var FinTsOptions */
|
|
private $options;
|
|
/** @var Credentials|null */
|
|
private $credentials;
|
|
/** @var SanitizingLogger */
|
|
private $logger;
|
|
|
|
// The TAN mode and medium to be used for business transactions that require a TAN.
|
|
/** @var TanMode|int|null */
|
|
private $selectedTanMode;
|
|
/** @var string|null This is a {@link TanMedium::getName()}, but we don't have the {@link TanMedium} instance. */
|
|
private $selectedTanMedium;
|
|
|
|
// State that persists across physical connections, dialogs and even PHP processes.
|
|
/** @var BPD|null */
|
|
private $bpd;
|
|
/** @var int[]|null The IDs of the {@link TanMode}s from the BPD which the user is allowed to use. */
|
|
private $allowedTanModes;
|
|
/** @var UPD|null */
|
|
private $upd;
|
|
|
|
// State of the current connection/dialog with the bank.
|
|
/** @var Connection|null */
|
|
private $connection;
|
|
/** @var string|null */
|
|
private $kundensystemId;
|
|
/** @var string|null */
|
|
protected $dialogId;
|
|
/** @var int */
|
|
private $messageNumber = 1;
|
|
|
|
/**
|
|
* Use this factory to create new instances.
|
|
* @param FinTsOptions $options Configuration options for the connection to the bank.
|
|
* @param Credentials $credentials Authentication information for the user. Note: This library does not support
|
|
* anonymous connections, so the credentials are mandatory.
|
|
* @param string|null $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.
|
|
*/
|
|
public static function new(FinTsOptions $options, Credentials $credentials, ?string $persistedInstance = null): FinTs
|
|
{
|
|
$options->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;
|
|
}
|
|
}
|